commit 4070904db8f95e99eab47827d3bf4627d3736232 Author: Harvey Zuccon Date: Fri Feb 27 11:57:09 2026 +1100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db63d27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Build output +dist/ +build/ diff --git a/Downterm/Downterm.xcodeproj/project.pbxproj b/Downterm/Downterm.xcodeproj/project.pbxproj new file mode 100644 index 0000000..912a38f --- /dev/null +++ b/Downterm/Downterm.xcodeproj/project.pbxproj @@ -0,0 +1,444 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 2213F430F3D8A88033607CD2 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6359CF9DDF89413440300D /* NotchSettings.swift */; }; + 247C6F84E7ADE7AED43381E2 /* DowntermApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B671125208055E5334CB85E /* DowntermApp.swift */; }; + 26A767A10DDA77A690CC3C37 /* NotchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589421631401C819FE1A7BA9 /* NotchViewModel.swift */; }; + 295653929D5B9C0E6C90D6D7 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 032AECA58EA4C274BE9F3320 /* SwiftTerm */; }; + 37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B598809B19C892470DE7268 /* TerminalSession.swift */; }; + 4566E6B87CB62AF5C8D4B9D8 /* NotchShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC09C538CBE7C2D072008B2 /* NotchShape.swift */; }; + 4D5125E11B4DDBDB3DFACBAF /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B72743F178231E0B06DD3DE /* HotkeyManager.swift */; }; + 5B14FC23928E817FEB8D2A74 /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FEFF9074A85F02C43D9408 /* NotchWindow.swift */; }; + 7BD705CA6A34117929B362EC /* HotkeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490C53139360D970099D8F3D /* HotkeyRecorderView.swift */; }; + 7DE94234DC42EAB79896E176 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5CB3313B230019D0E988AFE /* SettingsView.swift */; }; + 7EA51C3720BED7E6189E057D /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F009B75D078A5070B5EA9738 /* TabBar.swift */; }; + 81A912E3E16165D999882078 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0779490DE9020FBBC464BE /* AppDelegate.swift */; }; + 888C45C650327089EBD39B2E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BA7F4716DA3909DA8BC381 /* ContentView.swift */; }; + 88EBFBB2292659EA7C42A8F9 /* HotkeyBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */; }; + 8FC731CF99AB4C1C10C16FAB /* PopoutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */; }; + A70FDB7EEEB895D475ED96E8 /* NotchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5C99B7CD7F60E55844E40C /* NotchState.swift */; }; + BE5E64222CF5689AC7088683 /* ScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A290D4D21D6C01A583A372 /* ScreenManager.swift */; }; + C4C93F2911B41BC19A2AE934 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */; }; + CC26C1677258E44F0D7B106A /* SwiftTermView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47000112562615C7E59489 /* SwiftTermView.swift */; }; + E9A064422790735E033E534F /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA6843B571B41986DE386F5F /* TerminalManager.swift */; }; + F0130A88D1453CD199FA65D7 /* NSScreen+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 02FEFF9074A85F02C43D9408 /* NotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindow.swift; sourceTree = ""; }; + 0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; + 15A290D4D21D6C01A583A372 /* ScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenManager.swift; sourceTree = ""; }; + 1E47000112562615C7E59489 /* SwiftTermView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTermView.swift; sourceTree = ""; }; + 1FC09C538CBE7C2D072008B2 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = ""; }; + 20BA7F4716DA3909DA8BC381 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 2C5C99B7CD7F60E55844E40C /* NotchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchState.swift; sourceTree = ""; }; + 3B72743F178231E0B06DD3DE /* HotkeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyManager.swift; sourceTree = ""; }; + 490C53139360D970099D8F3D /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = ""; }; + 4B671125208055E5334CB85E /* DowntermApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DowntermApp.swift; sourceTree = ""; }; + 4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyBinding.swift; sourceTree = ""; }; + 589421631401C819FE1A7BA9 /* NotchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchViewModel.swift; sourceTree = ""; }; + 5C0779490DE9020FBBC464BE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 665CFC051CF185B71199608D /* Downterm.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Downterm.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7B598809B19C892470DE7268 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = ""; }; + 9547A79F60E46F4521A70674 /* Downterm.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Downterm.entitlements; sourceTree = ""; }; + AA6359CF9DDF89413440300D /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = ""; }; + BA6843B571B41986DE386F5F /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; + C5CB3313B230019D0E988AFE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoutWindowController.swift; sourceTree = ""; }; + F009B75D078A5070B5EA9738 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; + F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+Extensions.swift"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 6085DF2BDFFB2A99C4ABD514 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 295653929D5B9C0E6C90D6D7 /* SwiftTerm in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0EF94ED46B4860C241540F0A /* Resources */ = { + isa = PBXGroup; + children = ( + 9547A79F60E46F4521A70674 /* Downterm.entitlements */, + ); + path = Resources; + sourceTree = ""; + }; + 27C90448ECAC906F0DA429C0 /* Managers */ = { + isa = PBXGroup; + children = ( + 3B72743F178231E0B06DD3DE /* HotkeyManager.swift */, + EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */, + 15A290D4D21D6C01A583A372 /* ScreenManager.swift */, + 0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */, + ); + path = Managers; + sourceTree = ""; + }; + 792DD4F8C079680683D8FF7A /* Products */ = { + isa = PBXGroup; + children = ( + 665CFC051CF185B71199608D /* Downterm.app */, + ); + name = Products; + sourceTree = ""; + }; + 869AD33E1CDEB9CBAD401BA6 /* Models */ = { + isa = PBXGroup; + children = ( + 4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */, + AA6359CF9DDF89413440300D /* NotchSettings.swift */, + 2C5C99B7CD7F60E55844E40C /* NotchState.swift */, + 589421631401C819FE1A7BA9 /* NotchViewModel.swift */, + BA6843B571B41986DE386F5F /* TerminalManager.swift */, + 7B598809B19C892470DE7268 /* TerminalSession.swift */, + ); + path = Models; + sourceTree = ""; + }; + 8D95E0324E6AFC9E4DC0C087 /* Extensions */ = { + isa = PBXGroup; + children = ( + F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 9E1CA4816F67033BBD52D8A3 /* Downterm */ = { + isa = PBXGroup; + children = ( + 5C0779490DE9020FBBC464BE /* AppDelegate.swift */, + 20BA7F4716DA3909DA8BC381 /* ContentView.swift */, + 4B671125208055E5334CB85E /* DowntermApp.swift */, + F32F526005A2589010E63C76 /* Components */, + 8D95E0324E6AFC9E4DC0C087 /* Extensions */, + 27C90448ECAC906F0DA429C0 /* Managers */, + 869AD33E1CDEB9CBAD401BA6 /* Models */, + 0EF94ED46B4860C241540F0A /* Resources */, + C2B8955F4D0A1DAA7E60326A /* Views */, + ); + path = Downterm; + sourceTree = ""; + }; + C2B8955F4D0A1DAA7E60326A /* Views */ = { + isa = PBXGroup; + children = ( + C5CB3313B230019D0E988AFE /* SettingsView.swift */, + ); + path = Views; + sourceTree = ""; + }; + F32F526005A2589010E63C76 /* Components */ = { + isa = PBXGroup; + children = ( + 490C53139360D970099D8F3D /* HotkeyRecorderView.swift */, + 1FC09C538CBE7C2D072008B2 /* NotchShape.swift */, + 02FEFF9074A85F02C43D9408 /* NotchWindow.swift */, + 1E47000112562615C7E59489 /* SwiftTermView.swift */, + F009B75D078A5070B5EA9738 /* TabBar.swift */, + ); + path = Components; + sourceTree = ""; + }; + FC6F23514BFE2235BD4154E8 = { + isa = PBXGroup; + children = ( + 9E1CA4816F67033BBD52D8A3 /* Downterm */, + 792DD4F8C079680683D8FF7A /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1485207FA11756EC2DF4F08B /* Downterm */ = { + isa = PBXNativeTarget; + buildConfigurationList = 74CB98309F5464CDCB00C63A /* Build configuration list for PBXNativeTarget "Downterm" */; + buildPhases = ( + F3C6D5CD1247D246A3F6F7AB /* Sources */, + 6085DF2BDFFB2A99C4ABD514 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Downterm; + packageProductDependencies = ( + 032AECA58EA4C274BE9F3320 /* SwiftTerm */, + ); + productName = Downterm; + productReference = 665CFC051CF185B71199608D /* Downterm.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F72A983360EF3F99042A4895 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = D1C4019FEAFC83BB053C9E6E /* Build configuration list for PBXProject "Downterm" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = FC6F23514BFE2235BD4154E8; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 80F03B77566BF59C9941EAD4 /* XCRemoteSwiftPackageReference "SwiftTerm" */, + ); + preferredProjectObjectVersion = 77; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1485207FA11756EC2DF4F08B /* Downterm */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + F3C6D5CD1247D246A3F6F7AB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81A912E3E16165D999882078 /* AppDelegate.swift in Sources */, + 888C45C650327089EBD39B2E /* ContentView.swift in Sources */, + 247C6F84E7ADE7AED43381E2 /* DowntermApp.swift in Sources */, + 88EBFBB2292659EA7C42A8F9 /* HotkeyBinding.swift in Sources */, + 4D5125E11B4DDBDB3DFACBAF /* HotkeyManager.swift in Sources */, + 7BD705CA6A34117929B362EC /* HotkeyRecorderView.swift in Sources */, + F0130A88D1453CD199FA65D7 /* NSScreen+Extensions.swift in Sources */, + 2213F430F3D8A88033607CD2 /* NotchSettings.swift in Sources */, + 4566E6B87CB62AF5C8D4B9D8 /* NotchShape.swift in Sources */, + A70FDB7EEEB895D475ED96E8 /* NotchState.swift in Sources */, + 26A767A10DDA77A690CC3C37 /* NotchViewModel.swift in Sources */, + 5B14FC23928E817FEB8D2A74 /* NotchWindow.swift in Sources */, + 8FC731CF99AB4C1C10C16FAB /* PopoutWindowController.swift in Sources */, + BE5E64222CF5689AC7088683 /* ScreenManager.swift in Sources */, + 7DE94234DC42EAB79896E176 /* SettingsView.swift in Sources */, + C4C93F2911B41BC19A2AE934 /* SettingsWindowController.swift in Sources */, + CC26C1677258E44F0D7B106A /* SwiftTermView.swift in Sources */, + 7EA51C3720BED7E6189E057D /* TabBar.swift in Sources */, + E9A064422790735E033E534F /* TerminalManager.swift in Sources */, + 37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 0B8C784EF064E46C44076D6B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Downterm/Resources/Downterm.entitlements; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Downterm/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.downterm.app; + PRODUCT_NAME = Downterm; + SDKROOT = macosx; + }; + name = Release; + }; + 3595A9212275B9AEC4448C64 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.10; + }; + name = Debug; + }; + 7020C02C1BDF63690CC9A3AC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Downterm/Resources/Downterm.entitlements; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Downterm/Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.downterm.app; + PRODUCT_NAME = Downterm; + SDKROOT = macosx; + }; + name = Debug; + }; + BC741C4C821EA399B645E547 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.10; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 74CB98309F5464CDCB00C63A /* Build configuration list for PBXNativeTarget "Downterm" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7020C02C1BDF63690CC9A3AC /* Debug */, + 0B8C784EF064E46C44076D6B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D1C4019FEAFC83BB053C9E6E /* Build configuration list for PBXProject "Downterm" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3595A9212275B9AEC4448C64 /* Debug */, + BC741C4C821EA399B645E547 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 80F03B77566BF59C9941EAD4 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 032AECA58EA4C274BE9F3320 /* SwiftTerm */ = { + isa = XCSwiftPackageProductDependency; + package = 80F03B77566BF59C9941EAD4 /* XCRemoteSwiftPackageReference "SwiftTerm" */; + productName = SwiftTerm; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = F72A983360EF3F99042A4895 /* Project object */; +} diff --git a/Downterm/Downterm.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Downterm/Downterm.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Downterm/Downterm.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Downterm/Downterm.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Downterm/Downterm.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..dd6dd75 --- /dev/null +++ b/Downterm/Downterm.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "34fc6ded3af11d97770b2e20b5e3cfd72f9d3309ae17ef3278b95041f16c02dc", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm.git", + "state" : { + "revision" : "b1262db5b6bea699a8260a8c66999436c508ca56", + "version" : "1.11.2" + } + } + ], + "version" : 3 +} diff --git a/Downterm/Downterm.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate b/Downterm/Downterm.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..86a7023 Binary files /dev/null and b/Downterm/Downterm.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Downterm/Downterm.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist b/Downterm/Downterm.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..14a7c50 --- /dev/null +++ b/Downterm/Downterm.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Downterm.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/Downterm/Downterm/AppDelegate.swift b/Downterm/Downterm/AppDelegate.swift new file mode 100644 index 0000000..fe435ae --- /dev/null +++ b/Downterm/Downterm/AppDelegate.swift @@ -0,0 +1,69 @@ +import AppKit +import Combine + +/// Application delegate that bootstraps the notch overlay system. +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate { + + private var cancellables = Set() + + func applicationDidFinishLaunching(_ notification: Notification) { + NotchSettings.registerDefaults() + NSApp.setActivationPolicy(.accessory) + + ScreenManager.shared.start() + observeDisplayPreference() + observeSizePreferences() + observeFontSizeChanges() + } + + func applicationWillTerminate(_ notification: Notification) { + ScreenManager.shared.stop() + } + + // MARK: - Preference observers + + /// Only rebuild windows when the display-count preference changes. + private func observeDisplayPreference() { + UserDefaults.standard.publisher(for: \.showOnAllDisplays) + .removeDuplicates() + .dropFirst() + .sink { _ in + ScreenManager.shared.rebuildWindows() + } + .store(in: &cancellables) + } + + /// Reposition (not rebuild) when any sizing preference changes. + private func observeSizePreferences() { + NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) + .debounce(for: .milliseconds(300), scheduler: RunLoop.main) + .sink { _ in + ScreenManager.shared.repositionWindows() + } + .store(in: &cancellables) + } + + /// Live-update terminal font size across all sessions. + private func observeFontSizeChanges() { + UserDefaults.standard.publisher(for: \.terminalFontSize) + .removeDuplicates() + .sink { newSize in + guard newSize > 0 else { return } + TerminalManager.shared.updateAllFontSizes(CGFloat(newSize)) + } + .store(in: &cancellables) + } +} + +// MARK: - KVO key paths + +private extension UserDefaults { + @objc var terminalFontSize: Double { + double(forKey: NotchSettings.Keys.terminalFontSize) + } + + @objc var showOnAllDisplays: Bool { + bool(forKey: NotchSettings.Keys.showOnAllDisplays) + } +} diff --git a/Downterm/Downterm/Components/HotkeyRecorderView.swift b/Downterm/Downterm/Components/HotkeyRecorderView.swift new file mode 100644 index 0000000..a28ace4 --- /dev/null +++ b/Downterm/Downterm/Components/HotkeyRecorderView.swift @@ -0,0 +1,111 @@ +import SwiftUI +import AppKit + +/// A clickable field that records a keyboard shortcut when focused. +/// Click it, press a key combination, and it saves the binding. +struct HotkeyRecorderView: View { + let label: String + @Binding var binding: HotkeyBinding + + @State private var isRecording = false + + var body: some View { + HStack { + Text(label) + .frame(width: 140, alignment: .leading) + + HotkeyRecorderField(binding: $binding, isRecording: $isRecording) + .frame(width: 120, height: 24) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isRecording ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(isRecording ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + } +} + +/// NSViewRepresentable that captures key events when focused. +struct HotkeyRecorderField: NSViewRepresentable { + @Binding var binding: HotkeyBinding + @Binding var isRecording: Bool + + func makeNSView(context: Context) -> HotkeyNSView { + let view = HotkeyNSView() + view.onKeyRecorded = { newBinding in + binding = newBinding + isRecording = false + } + view.onFocusChanged = { focused in + isRecording = focused + } + return view + } + + func updateNSView(_ nsView: HotkeyNSView, context: Context) { + nsView.currentLabel = binding.displayString + nsView.showRecording = isRecording + nsView.needsDisplay = true + } +} + +/// The actual NSView that handles key capture. +class HotkeyNSView: NSView { + var currentLabel: String = "" + var showRecording: Bool = false + var onKeyRecorded: ((HotkeyBinding) -> Void)? + var onFocusChanged: ((Bool) -> Void)? + + override var acceptsFirstResponder: Bool { true } + + override func draw(_ dirtyRect: NSRect) { + let text = showRecording ? "Press keys…" : currentLabel + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium), + .foregroundColor: showRecording ? NSColor.controlAccentColor : NSColor.labelColor + ] + let str = NSAttributedString(string: text, attributes: attrs) + let size = str.size() + let point = NSPoint( + x: (bounds.width - size.width) / 2, + y: (bounds.height - size.height) / 2 + ) + str.draw(at: point) + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + } + + override func becomeFirstResponder() -> Bool { + onFocusChanged?(true) + return true + } + + override func resignFirstResponder() -> Bool { + onFocusChanged?(false) + return true + } + + override func keyDown(with event: NSEvent) { + guard showRecording else { + super.keyDown(with: event) + return + } + + let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option] + let masked = event.modifierFlags.intersection(.deviceIndependentFlagsMask).intersection(relevantFlags) + + // Require at least one modifier key + guard !masked.isEmpty else { return } + + let binding = HotkeyBinding(modifiers: masked.rawValue, keyCode: event.keyCode) + onKeyRecorded?(binding) + + // Resign first responder after recording + window?.makeFirstResponder(nil) + } +} diff --git a/Downterm/Downterm/Components/NotchShape.swift b/Downterm/Downterm/Components/NotchShape.swift new file mode 100644 index 0000000..71cb44e --- /dev/null +++ b/Downterm/Downterm/Components/NotchShape.swift @@ -0,0 +1,109 @@ +import SwiftUI + +/// Custom SwiftUI Shape that draws the characteristic MacBook notch outline. +/// Both top and bottom corner radii are animatable, enabling smooth transitions +/// between the compact closed state and the expanded open state. +/// +/// The shape uses quadratic Bezier curves to produce the distinctive +/// "ear" ramps on each side when closed, and a clean rounded-bottom +/// rectangle when open (topCornerRadius approaches 0). +struct NotchShape: Shape { + + /// Radius applied to the top-left and top-right outer corners (the "ears"). + /// When close to 0, the top corners become sharp and the shape is a + /// rectangle with rounded bottom corners — no visible ear ramps. + var topCornerRadius: CGFloat + + /// Radius applied to the bottom-left and bottom-right inner corners. + var bottomCornerRadius: CGFloat + + // MARK: - Animatable conformance + + var animatableData: AnimatablePair { + get { AnimatablePair(topCornerRadius, bottomCornerRadius) } + set { + topCornerRadius = newValue.first + bottomCornerRadius = newValue.second + } + } + + // MARK: - Path + + func path(in rect: CGRect) -> Path { + var path = Path() + + let minX = rect.minX + let maxX = rect.maxX + let minY = rect.minY + let maxY = rect.maxY + let width = rect.width + let height = rect.height + + let topR = min(topCornerRadius, width / 4, height / 2) + let botR = min(bottomCornerRadius, width / 4, height / 2) + + // Start at the top-left corner of the rect + path.move(to: CGPoint(x: minX, y: minY)) + + if topR > 0.5 { + // Top-left ear: curve down from the top edge + path.addQuadCurve( + to: CGPoint(x: minX + topR, y: minY + topR), + control: CGPoint(x: minX, y: minY + topR) + ) + } else { + path.addLine(to: CGPoint(x: minX, y: minY)) + } + + // Left edge down to bottom-left corner area + path.addLine(to: CGPoint(x: minX + topR, y: maxY - botR)) + + // Bottom-left inner corner + path.addQuadCurve( + to: CGPoint(x: minX + topR + botR, y: maxY), + control: CGPoint(x: minX + topR, y: maxY) + ) + + // Bottom edge across + path.addLine(to: CGPoint(x: maxX - topR - botR, y: maxY)) + + // Bottom-right inner corner + path.addQuadCurve( + to: CGPoint(x: maxX - topR, y: maxY - botR), + control: CGPoint(x: maxX - topR, y: maxY) + ) + + // Right edge up to top-right ear area + path.addLine(to: CGPoint(x: maxX - topR, y: minY + topR)) + + if topR > 0.5 { + // Top-right ear: curve back up to the top edge + path.addQuadCurve( + to: CGPoint(x: maxX, y: minY), + control: CGPoint(x: maxX, y: minY + topR) + ) + } else { + path.addLine(to: CGPoint(x: maxX, y: minY)) + } + + path.closeSubpath() + return path + } +} + +// MARK: - Convenience initializers + +extension NotchShape { + + /// Closed-state shape with tight corner radii that mimic the physical notch. + static var closed: NotchShape { + NotchShape(topCornerRadius: 6, bottomCornerRadius: 14) + } + + /// Open-state shape: no ear ramps, just rounded bottom corners. + /// topCornerRadius is near-zero so the ears disappear and the panel + /// extends flush to the top edge of the screen. + static var opened: NotchShape { + NotchShape(topCornerRadius: 0, bottomCornerRadius: 24) + } +} diff --git a/Downterm/Downterm/Components/NotchWindow.swift b/Downterm/Downterm/Components/NotchWindow.swift new file mode 100644 index 0000000..2bb8f2b --- /dev/null +++ b/Downterm/Downterm/Components/NotchWindow.swift @@ -0,0 +1,78 @@ +import AppKit +import SwiftUI + +/// Borderless, floating NSPanel that hosts the notch overlay. +/// When the notch is open the window accepts key status so the +/// terminal can receive keyboard input. On resignKey the +/// `onResignKey` closure fires to close the notch. +class NotchWindow: NSPanel { + + var isNotchOpen: Bool = false + + /// Called when the window loses key status while the notch is open. + var onResignKey: (() -> Void)? + + override init( + contentRect: NSRect, + styleMask style: NSWindow.StyleMask, + backing backingStoreType: NSWindow.BackingStoreType, + defer flag: Bool + ) { + // Start as a plain borderless utility panel. + // .nonactivatingPanel is NOT included so the window can + // properly accept key status when the notch opens. + super.init( + contentRect: contentRect, + styleMask: [.borderless, .utilityWindow, .nonactivatingPanel], + backing: .buffered, + defer: flag + ) + configureWindow() + } + + private func configureWindow() { + isOpaque = false + backgroundColor = .clear + + isFloatingPanel = true + level = .mainMenu + 3 + + titleVisibility = .hidden + titlebarAppearsTransparent = true + hasShadow = false + + isMovable = false + isMovableByWindowBackground = false + + collectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .fullScreenAuxiliary, + .ignoresCycle + ] + + appearance = NSAppearance(named: .darkAqua) + + // Accepts mouse events when the app is NOT active so the + // user can click the closed notch to open it. + acceptsMouseMovedEvents = true + } + + // MARK: - Key window management + + override var canBecomeKey: Bool { isNotchOpen } + override var canBecomeMain: Bool { false } + + override func resignKey() { + super.resignKey() + if isNotchOpen { + // Brief async dispatch so the new key window settles first — + // avoids closing when we're just transferring focus between + // our own windows (e.g. opening settings). + DispatchQueue.main.async { [weak self] in + guard let self, self.isNotchOpen else { return } + self.onResignKey?() + } + } + } +} diff --git a/Downterm/Downterm/Components/SwiftTermView.swift b/Downterm/Downterm/Components/SwiftTermView.swift new file mode 100644 index 0000000..68bc76b --- /dev/null +++ b/Downterm/Downterm/Components/SwiftTermView.swift @@ -0,0 +1,46 @@ +import SwiftUI +import SwiftTerm + +/// NSViewRepresentable wrapper that embeds a SwiftTerm TerminalView. +/// The container has a solid black background — matching the notch panel. +/// All transparency is handled by the single `.opacity()` on ContentView. +struct SwiftTermView: NSViewRepresentable { + + let session: TerminalSession + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.wantsLayer = true + container.layer?.backgroundColor = NSColor.black.cgColor + embedTerminalView(in: container) + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + let tv = session.terminalView + + if nsView.subviews.first !== tv { + nsView.subviews.forEach { $0.removeFromSuperview() } + embedTerminalView(in: nsView) + } + + DispatchQueue.main.async { + if let window = nsView.window, window.isKeyWindow { + window.makeFirstResponder(tv) + } + } + } + + private func embedTerminalView(in container: NSView) { + let tv = session.terminalView + tv.removeFromSuperview() + container.addSubview(tv) + tv.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tv.topAnchor.constraint(equalTo: container.topAnchor), + tv.bottomAnchor.constraint(equalTo: container.bottomAnchor), + tv.leadingAnchor.constraint(equalTo: container.leadingAnchor), + tv.trailingAnchor.constraint(equalTo: container.trailingAnchor), + ]) + } +} diff --git a/Downterm/Downterm/Components/TabBar.swift b/Downterm/Downterm/Components/TabBar.swift new file mode 100644 index 0000000..88953f6 --- /dev/null +++ b/Downterm/Downterm/Components/TabBar.swift @@ -0,0 +1,73 @@ +import SwiftUI + +/// Horizontal tab bar at the bottom of the open notch panel. +/// Solid black background to match the rest of the notch — +/// the single `.opacity()` on ContentView handles transparency. +struct TabBar: View { + + @ObservedObject var terminalManager: TerminalManager + + var body: some View { + HStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(Array(terminalManager.tabs.enumerated()), id: \.element.id) { index, tab in + tabButton(for: tab, at: index) + } + } + .padding(.horizontal, 4) + } + + Spacer() + + Button { + terminalManager.newTab() + } label: { + Image(systemName: "plus") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + } + .buttonStyle(.plain) + .padding(.horizontal, 8) + } + .frame(height: 28) + .background(.black) + } + + @ViewBuilder + private func tabButton(for tab: TerminalSession, at index: Int) -> some View { + let isActive = index == terminalManager.activeTabIndex + + HStack(spacing: 4) { + Text(abbreviateTitle(tab.title)) + .font(.system(size: 11)) + .lineLimit(1) + .foregroundStyle(isActive ? .white : .white.opacity(0.5)) + + if isActive && terminalManager.tabs.count > 1 { + Button { + terminalManager.closeTab(at: index) + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.white.opacity(0.4)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isActive ? Color.white.opacity(0.12) : Color.clear) + ) + .contentShape(Rectangle()) + .onTapGesture { + terminalManager.switchToTab(at: index) + } + } + + private func abbreviateTitle(_ title: String) -> String { + title.count <= 24 ? title : String(title.prefix(22)) + "…" + } +} diff --git a/Downterm/Downterm/ContentView.swift b/Downterm/Downterm/ContentView.swift new file mode 100644 index 0000000..7eb79ae --- /dev/null +++ b/Downterm/Downterm/ContentView.swift @@ -0,0 +1,166 @@ +import SwiftUI +import SwiftTerm + +/// Main view rendered inside each NotchWindow. +/// +/// Opacity strategy: EVERY element has a solid black background. +/// A single `.opacity(notchOpacity)` is applied at the outermost +/// level so everything becomes uniformly transparent — no double +/// layering, no mismatched areas. +struct ContentView: View { + + @ObservedObject var vm: NotchViewModel + @ObservedObject var terminalManager: TerminalManager + + // MARK: - Settings + + @AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover + @AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration + + @AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow + @AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius + @AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity + @AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling + @AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity + @AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius + + @AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverSpringResponse = NotchSettings.Defaults.hoverSpringResponse + @AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping + + @State private var hoverTask: Task? + + private var hoverAnimation: Animation { + .interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping) + } + + private var currentShape: NotchShape { + vm.notchState == .open + ? (cornerRadiusScaling ? .opened : NotchShape(topCornerRadius: 0, bottomCornerRadius: 14)) + : .closed + } + + // MARK: - Body + + var body: some View { + notchBody + .frame( + width: vm.notchSize.width, + height: vm.notchState == .open ? vm.notchSize.height : vm.closedNotchSize.height, + alignment: .top + ) + .background(.black) + .clipShape(currentShape) + .overlay(alignment: .top) { + Rectangle().fill(.black).frame(height: 1) + } + .shadow( + color: enableShadow ? Color.black.opacity(shadowOpacity) : .clear, + radius: enableShadow ? shadowRadius : 0 + ) + // Single opacity control — everything inside is solid black, + // so this one modifier makes it all uniformly transparent. + .opacity(notchOpacity) + .blur(radius: blurRadius) + .animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState) + .animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.width) + .animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.height) + .onHover { handleHover($0) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .edgesIgnoringSafeArea(.all) + } + + // MARK: - Content + + @ViewBuilder + private var notchBody: some View { + if vm.notchState == .open { + openContent + .transition(.opacity) + } else { + closedContent + } + } + + private var closedContent: some View { + HStack { + Spacer() + Text(abbreviate(terminalManager.activeTitle)) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 8) + .background(.black) + } + + /// Open layout: VStack with toolbar row on top, terminal in the middle, + /// tab bar at the bottom. Every section has a black background. + private var openContent: some View { + VStack(spacing: 0) { + // Toolbar row — right-aligned, solid black + HStack { + Spacer() + toolbarButton(icon: "arrow.up.forward.square", help: "Detach tab") { + if let session = terminalManager.detachActiveTab() { + PopoutWindowController.shared.popout(session: session) + } + } + toolbarButton(icon: "gearshape.fill", help: "Settings") { + SettingsWindowController.shared.showSettings() + } + } + .padding(.top, 6) + .padding(.trailing, 10) + .padding(.bottom, 2) + .background(.black) + + // Terminal — fills remaining space + if let session = terminalManager.activeTab { + SwiftTermView(session: session) + .id(session.id) + .padding(.leading, 10) + .padding(.trailing, 10) + } + + // Tab bar + TabBar(terminalManager: terminalManager) + } + .background(.black) + } + + private func toolbarButton(icon: String, help: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.45)) + .padding(4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(help) + } + + // MARK: - Hover + + private func handleHover(_ hovering: Bool) { + if hovering { + withAnimation(hoverAnimation) { vm.isHovering = true } + guard openNotchOnHover, vm.notchState == .closed else { return } + + hoverTask?.cancel() + hoverTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(minimumHoverDuration * 1_000_000_000)) + guard !Task.isCancelled, vm.isHovering else { return } + vm.requestOpen?() + } + } else { + hoverTask?.cancel() + withAnimation(hoverAnimation) { vm.isHovering = false } + } + } + + private func abbreviate(_ title: String) -> String { + title.count <= 30 ? title : String(title.prefix(28)) + "…" + } +} diff --git a/Downterm/Downterm/DowntermApp.swift b/Downterm/Downterm/DowntermApp.swift new file mode 100644 index 0000000..4539bb4 --- /dev/null +++ b/Downterm/Downterm/DowntermApp.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// Main entry point for the Downterm application. +/// Provides a MenuBarExtra for quick access to settings and app controls. +/// The notch windows and terminal sessions are managed by AppDelegate + ScreenManager. +@main +struct DowntermApp: App { + + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + @AppStorage(NotchSettings.Keys.showMenuBarIcon) + private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon + + var body: some Scene { + MenuBarExtra("Downterm", systemImage: "terminal", isInserted: $showMenuBarIcon) { + Button("Toggle Notch") { + ScreenManager.shared.toggleNotchOnActiveScreen() + } + .keyboardShortcut(.return, modifiers: .command) + + Divider() + + Button("Settings...") { + SettingsWindowController.shared.showSettings() + } + .keyboardShortcut(",", modifiers: .command) + + Divider() + + Button("Quit Downterm") { + NSApplication.shared.terminate(nil) + } + .keyboardShortcut("Q", modifiers: .command) + } + } +} diff --git a/Downterm/Downterm/Extensions/NSScreen+Extensions.swift b/Downterm/Downterm/Extensions/NSScreen+Extensions.swift new file mode 100644 index 0000000..a7acd92 --- /dev/null +++ b/Downterm/Downterm/Extensions/NSScreen+Extensions.swift @@ -0,0 +1,85 @@ +import AppKit + +extension NSScreen { + + // MARK: - Stable display identifier + + /// Returns a stable UUID string for this screen by querying CoreGraphics. + /// Falls back to the localized name if the CG UUID is unavailable. + var displayUUID: String { + guard let screenNumber = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else { + return localizedName + } + guard let uuid = CGDisplayCreateUUIDFromDisplayID(screenNumber) else { + return localizedName + } + return CFUUIDCreateString(nil, uuid.takeUnretainedValue()) as String + } + + // MARK: - Notch detection + + /// `true` when this screen has a physical camera notch (safe area inset at top > 0). + var hasNotch: Bool { + safeAreaInsets.top > 0 + } + + // MARK: - Closed notch sizing + + /// Computes the closed-state notch size for this screen, + /// respecting the user's height mode and custom height preferences. + func closedNotchSize() -> CGSize { + let height = closedNotchHeight() + let width = closedNotchWidth() + return CGSize(width: width, height: height) + } + + /// Height of the closed notch bar, determined by the user's chosen mode. + private func closedNotchHeight() -> CGFloat { + let defaults = UserDefaults.standard + + if hasNotch { + let mode = NotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.notchHeightMode)) + ?? .matchRealNotchSize + switch mode { + case .matchRealNotchSize: + return safeAreaInsets.top + case .matchMenuBar: + return menuBarHeight() + case .custom: + return defaults.double(forKey: NotchSettings.Keys.notchHeight) + } + } else { + let mode = NonNotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.nonNotchHeightMode)) + ?? .matchMenuBar + switch mode { + case .matchMenuBar: + return menuBarHeight() + case .custom: + return defaults.double(forKey: NotchSettings.Keys.nonNotchHeight) + } + } + } + + /// Width of the closed notch. + /// On notch screens, spans from one auxiliary top area to the other. + /// On non-notch screens, uses a reasonable fixed width. + private func closedNotchWidth() -> CGFloat { + if hasNotch { + if let topLeft = auxiliaryTopLeftArea, + let topRight = auxiliaryTopRightArea { + // The notch occupies the space between the two menu bar segments + return frame.width - topLeft.width - topRight.width + 4 + } + // Fallback for older API — approximate from safe area + return 220 + } else { + // Non-notch screens: a compact simulated notch + return 220 + } + } + + /// The effective menu bar height for this screen. + private func menuBarHeight() -> CGFloat { + return frame.maxY - visibleFrame.maxY + } +} diff --git a/Downterm/Downterm/Managers/HotkeyManager.swift b/Downterm/Downterm/Managers/HotkeyManager.swift new file mode 100644 index 0000000..cee9ecc --- /dev/null +++ b/Downterm/Downterm/Managers/HotkeyManager.swift @@ -0,0 +1,241 @@ +import AppKit +import Carbon.HIToolbox + +/// Manages global and local hotkeys. +/// +/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works +/// system-wide without Accessibility permission. Tab-level hotkeys +/// use a local `NSEvent` monitor (only fires when our app is active). +class HotkeyManager { + + static let shared = HotkeyManager() + + // MARK: - Callbacks + + var onToggle: (() -> Void)? + var onNewTab: (() -> Void)? + var onCloseTab: (() -> Void)? + var onNextTab: (() -> Void)? + var onPreviousTab: (() -> Void)? + var onDetachTab: (() -> Void)? + var onSwitchToTab: ((Int) -> Void)? + + /// Tab-level hotkeys only fire when the notch is open. + var isNotchOpen: Bool = false + + private var hotKeyRef: EventHotKeyRef? + private var eventHandlerRef: EventHandlerRef? + private var localMonitor: Any? + private var defaultsObserver: NSObjectProtocol? + + private init() {} + + // MARK: - Resolved bindings (live from UserDefaults) + + private var toggleBinding: HotkeyBinding { + binding(for: NotchSettings.Keys.hotkeyToggle) ?? .cmdReturn + } + private var newTabBinding: HotkeyBinding { + binding(for: NotchSettings.Keys.hotkeyNewTab) ?? .cmdT + } + private var closeTabBinding: HotkeyBinding { + binding(for: NotchSettings.Keys.hotkeyCloseTab) ?? .cmdW + } + private var nextTabBinding: HotkeyBinding { + binding(for: NotchSettings.Keys.hotkeyNextTab) ?? .cmdShiftRB + } + private var prevTabBinding: HotkeyBinding { + binding(for: NotchSettings.Keys.hotkeyPreviousTab) ?? .cmdShiftLB + } + private var detachBinding: HotkeyBinding { + binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD + } + + private func binding(for key: String) -> HotkeyBinding? { + guard let json = UserDefaults.standard.string(forKey: key) else { return nil } + return HotkeyBinding.fromJSON(json) + } + + // MARK: - Start / Stop + + func start() { + installCarbonHandler() + registerToggleHotkey() + installLocalMonitor() + observeToggleHotkeyChanges() + } + + func stop() { + unregisterToggleHotkey() + removeCarbonHandler() + removeLocalMonitor() + if let obs = defaultsObserver { + NotificationCenter.default.removeObserver(obs) + defaultsObserver = nil + } + } + + // MARK: - Carbon global hotkey (toggle) + + /// Installs a Carbon event handler that receives `kEventHotKeyPressed` + /// events when a registered hotkey fires — works system-wide. + private func installCarbonHandler() { + var eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + + // Closure must not capture self — uses the singleton accessor instead. + let status = InstallEventHandler( + GetApplicationEventTarget(), + { (_: EventHandlerCallRef?, theEvent: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in + guard let theEvent else { return OSStatus(eventNotHandledErr) } + + var hotKeyID = EventHotKeyID() + let err = GetEventParameter( + theEvent, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + guard err == noErr else { return err } + + if hotKeyID.id == 1 { + DispatchQueue.main.async { + HotkeyManager.shared.onToggle?() + } + } + return noErr + }, + 1, + &eventType, + nil, + &eventHandlerRef + ) + + if status != noErr { + print("[HotkeyManager] Failed to install Carbon event handler: \(status)") + } + } + + private func registerToggleHotkey() { + unregisterToggleHotkey() + + let binding = toggleBinding + let carbonMods = carbonModifiers(from: binding.modifiers) + var hotKeyID = EventHotKeyID( + signature: OSType(0x444E5452), // "DNTR" + id: 1 + ) + + let status = RegisterEventHotKey( + UInt32(binding.keyCode), + carbonMods, + hotKeyID, + GetApplicationEventTarget(), + 0, + &hotKeyRef + ) + + if status != noErr { + print("[HotkeyManager] Failed to register toggle hotkey: \(status)") + } + } + + private func unregisterToggleHotkey() { + if let ref = hotKeyRef { + UnregisterEventHotKey(ref) + hotKeyRef = nil + } + } + + private func removeCarbonHandler() { + if let ref = eventHandlerRef { + RemoveEventHandler(ref) + eventHandlerRef = nil + } + } + + /// Re-register the toggle hotkey whenever the user changes it in settings. + private func observeToggleHotkeyChanges() { + defaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.registerToggleHotkey() + } + } + + // MARK: - Local monitor (tab-level hotkeys, only when our app is active) + + private func installLocalMonitor() { + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + return self.handleLocalKeyEvent(event) ? nil : event + } + } + + private func removeLocalMonitor() { + if let m = localMonitor { + NSEvent.removeMonitor(m) + localMonitor = nil + } + } + + /// Handles tab-level hotkeys. Returns true if the event was consumed. + private func handleLocalKeyEvent(_ event: NSEvent) -> Bool { + // Tab hotkeys only when the notch is open and focused + guard isNotchOpen else { return false } + + if newTabBinding.matches(event) { + onNewTab?() + return true + } + if closeTabBinding.matches(event) { + onCloseTab?() + return true + } + if nextTabBinding.matches(event) { + onNextTab?() + return true + } + if prevTabBinding.matches(event) { + onPreviousTab?() + return true + } + if detachBinding.matches(event) { + onDetachTab?() + return true + } + + // Cmd+1 through Cmd+9 + if event.modifierFlags.contains(.command) { + let digitKeyCodes: [UInt16: Int] = [ + 18: 0, 19: 1, 20: 2, 21: 3, 23: 4, + 22: 5, 26: 6, 28: 7, 25: 8 + ] + if let tabIndex = digitKeyCodes[event.keyCode] { + onSwitchToTab?(tabIndex) + return true + } + } + + return false + } + + // MARK: - Carbon modifier conversion + + private func carbonModifiers(from nsModifiers: UInt) -> UInt32 { + var carbon: UInt32 = 0 + let flags = NSEvent.ModifierFlags(rawValue: nsModifiers) + if flags.contains(.command) { carbon |= UInt32(cmdKey) } + if flags.contains(.shift) { carbon |= UInt32(shiftKey) } + if flags.contains(.option) { carbon |= UInt32(optionKey) } + if flags.contains(.control) { carbon |= UInt32(controlKey) } + return carbon + } +} diff --git a/Downterm/Downterm/Managers/PopoutWindowController.swift b/Downterm/Downterm/Managers/PopoutWindowController.swift new file mode 100644 index 0000000..71a280f --- /dev/null +++ b/Downterm/Downterm/Managers/PopoutWindowController.swift @@ -0,0 +1,73 @@ +import AppKit +import SwiftUI +import SwiftTerm + +/// Manages standalone pop-out terminal windows for detached tabs. +/// Each detached tab gets its own resizable window with the terminal view. +@MainActor +class PopoutWindowController: NSObject, NSWindowDelegate { + + static let shared = PopoutWindowController() + + /// Tracks open pop-out windows so they aren't released prematurely. + private var windows: [UUID: NSWindow] = [:] + private var sessions: [UUID: TerminalSession] = [:] + + private override init() { + super.init() + } + + /// Creates a new standalone window for the given terminal session. + func popout(session: TerminalSession) { + let windowID = session.id + + let win = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 720, height: 480), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false + ) + win.title = session.title + win.appearance = NSAppearance(named: .darkAqua) + win.backgroundColor = .black + win.delegate = self + win.isReleasedWhenClosed = false + + // Embed the terminal view directly + let tv = session.terminalView + tv.removeFromSuperview() + tv.frame = NSRect(origin: .zero, size: win.contentView!.bounds.size) + tv.autoresizingMask = [.width, .height] + win.contentView?.addSubview(tv) + + win.center() + win.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + windows[windowID] = win + sessions[windowID] = session + + // Update window title when the terminal title changes + session.$title + .receive(on: RunLoop.main) + .sink { [weak win] title in win?.title = title } + .store(in: &popoutCancellables) + } + + private var popoutCancellables = Set() + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + guard let closingWindow = notification.object as? NSWindow else { return } + + // Find which session this window belongs to and clean up + if let entry = windows.first(where: { $0.value === closingWindow }) { + sessions[entry.key]?.terminate() + sessions.removeValue(forKey: entry.key) + windows.removeValue(forKey: entry.key) + } + } +} + +import Combine diff --git a/Downterm/Downterm/Managers/ScreenManager.swift b/Downterm/Downterm/Managers/ScreenManager.swift new file mode 100644 index 0000000..182863b --- /dev/null +++ b/Downterm/Downterm/Managers/ScreenManager.swift @@ -0,0 +1,250 @@ +import AppKit +import SwiftUI +import Combine + +/// Manages one NotchWindow per connected display. +/// Routes all open/close through centralized methods that handle +/// window activation, key status, and first responder assignment +/// so the terminal can receive keyboard input. +@MainActor +class ScreenManager: ObservableObject { + + static let shared = ScreenManager() + + private(set) var windows: [String: NotchWindow] = [:] + private(set) var viewModels: [String: NotchViewModel] = [:] + + @AppStorage(NotchSettings.Keys.showOnAllDisplays) + private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays + + private var cancellables = Set() + + private init() {} + + // MARK: - Lifecycle + + func start() { + observeScreenChanges() + rebuildWindows() + setupHotkeys() + } + + func stop() { + cleanupAllWindows() + cancellables.removeAll() + HotkeyManager.shared.stop() + } + + // MARK: - Hotkey wiring + + private func setupHotkeys() { + let hk = HotkeyManager.shared + let tm = TerminalManager.shared + + // Callbacks are invoked on the main thread by HotkeyManager. + // MainActor.assumeIsolated lets us safely call @MainActor methods. + hk.onToggle = { [weak self] in + MainActor.assumeIsolated { self?.toggleNotchOnActiveScreen() } + } + hk.onNewTab = { MainActor.assumeIsolated { tm.newTab() } } + hk.onCloseTab = { MainActor.assumeIsolated { tm.closeActiveTab() } } + hk.onNextTab = { MainActor.assumeIsolated { tm.nextTab() } } + hk.onPreviousTab = { MainActor.assumeIsolated { tm.previousTab() } } + hk.onDetachTab = { [weak self] in + MainActor.assumeIsolated { self?.detachActiveTab() } + } + hk.onSwitchToTab = { index in + MainActor.assumeIsolated { tm.switchToTab(at: index) } + } + + hk.start() + } + + // MARK: - Toggle + + func toggleNotchOnActiveScreen() { + let mouseLocation = NSEvent.mouseLocation + let targetScreen = NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } + ?? NSScreen.main + guard let screen = targetScreen else { return } + let uuid = screen.displayUUID + + // Close any other open notch first + for (otherUUID, otherVM) in viewModels where otherUUID != uuid { + if otherVM.notchState == .open { + closeNotch(screenUUID: otherUUID) + } + } + + if let vm = viewModels[uuid] { + if vm.notchState == .open { + closeNotch(screenUUID: uuid) + } else { + openNotch(screenUUID: uuid) + } + } + } + + // MARK: - Open / Close + + func openNotch(screenUUID: String) { + guard let vm = viewModels[screenUUID], + let window = windows[screenUUID] else { return } + + withAnimation(vm.openAnimation) { + vm.open() + } + + window.isNotchOpen = true + HotkeyManager.shared.isNotchOpen = true + + // Activate the app so the window can become key. + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + + // After layout settles, push keyboard focus to the terminal view + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + if let tv = TerminalManager.shared.activeTab?.terminalView { + window.makeFirstResponder(tv) + } + } + } + + func closeNotch(screenUUID: String) { + guard let vm = viewModels[screenUUID], + let window = windows[screenUUID] else { return } + + withAnimation(vm.closeAnimation) { + vm.close() + } + + window.isNotchOpen = false + HotkeyManager.shared.isNotchOpen = false + } + + private func detachActiveTab() { + if let session = TerminalManager.shared.detachActiveTab() { + PopoutWindowController.shared.popout(session: session) + } + } + + // MARK: - Window creation + + func rebuildWindows() { + cleanupAllWindows() + + let screens: [NSScreen] + if showOnAllDisplays { + screens = NSScreen.screens + } else { + screens = [NSScreen.main].compactMap { $0 } + } + for screen in screens { + createWindow(for: screen) + } + } + + private func createWindow(for screen: NSScreen) { + let uuid = screen.displayUUID + let vm = NotchViewModel(screenUUID: uuid) + + let shadowPadding: CGFloat = 20 + let openSize = vm.openNotchSize + let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5) + let windowHeight = openSize.height + shadowPadding + + let windowRect = NSRect( + x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2, + y: screen.frame.origin.y + screen.frame.height - windowHeight, + width: windowWidth, + height: windowHeight + ) + + let window = NotchWindow( + contentRect: windowRect, + styleMask: [.borderless, .nonactivatingPanel, .utilityWindow], + backing: .buffered, + defer: false + ) + + // Close the notch when the window loses focus + window.onResignKey = { [weak self] in + self?.closeNotch(screenUUID: uuid) + } + + // Wire the ViewModel callbacks so ContentView routes through us + vm.requestOpen = { [weak self] in + self?.openNotch(screenUUID: uuid) + } + vm.requestClose = { [weak self] in + self?.closeNotch(screenUUID: uuid) + } + + let hostingView = NSHostingView( + rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared) + .preferredColorScheme(.dark) + ) + hostingView.frame = NSRect(origin: .zero, size: windowRect.size) + window.contentView = hostingView + + window.setFrame(windowRect, display: true) + window.orderFrontRegardless() + + windows[uuid] = window + viewModels[uuid] = vm + } + + // MARK: - Repositioning + + func repositionWindows() { + for (uuid, window) in windows { + guard let screen = NSScreen.screens.first(where: { $0.displayUUID == uuid }) else { continue } + guard let vm = viewModels[uuid] else { continue } + + vm.refreshClosedSize() + + let shadowPadding: CGFloat = 20 + let openSize = vm.openNotchSize + let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5) + let windowHeight = openSize.height + shadowPadding + + let newFrame = NSRect( + x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2, + y: screen.frame.origin.y + screen.frame.height - windowHeight, + width: windowWidth, + height: windowHeight + ) + window.setFrame(newFrame, display: true) + } + } + + // MARK: - Cleanup + + private func cleanupAllWindows() { + for (_, window) in windows { + window.orderOut(nil) + window.close() + } + windows.removeAll() + viewModels.removeAll() + } + + // MARK: - Screen observation + + private func observeScreenChanges() { + NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification) + .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .sink { [weak self] _ in self?.handleScreenConfigurationChange() } + .store(in: &cancellables) + } + + private func handleScreenConfigurationChange() { + let currentUUIDs = Set(NSScreen.screens.map { $0.displayUUID }) + let knownUUIDs = Set(windows.keys) + if currentUUIDs != knownUUIDs { + rebuildWindows() + } else { + repositionWindows() + } + } +} diff --git a/Downterm/Downterm/Managers/SettingsWindowController.swift b/Downterm/Downterm/Managers/SettingsWindowController.swift new file mode 100644 index 0000000..f5324f3 --- /dev/null +++ b/Downterm/Downterm/Managers/SettingsWindowController.swift @@ -0,0 +1,58 @@ +import AppKit +import SwiftUI + +/// Singleton controller that manages the settings window. +/// When the settings panel opens, the app becomes a regular app +/// (visible in Dock / Cmd-Tab). When it closes, the app reverts +/// to an accessory (menu-bar-only) app. +class SettingsWindowController: NSObject, NSWindowDelegate { + + static let shared = SettingsWindowController() + + private var window: NSWindow? + + private override init() { + super.init() + } + + // MARK: - Show / Hide + + func showSettings() { + if let existing = window { + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let settingsView = SettingsView() + let hostingView = NSHostingView(rootView: settingsView) + + let win = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 500), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + win.title = "Downterm Settings" + win.contentView = hostingView + win.center() + win.delegate = self + win.isReleasedWhenClosed = false + + // Appear in Dock while settings are open + NSApp.setActivationPolicy(.regular) + + win.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + window = win + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + // Revert to accessory (menu-bar-only) mode + NSApp.setActivationPolicy(.accessory) + window = nil + } +} diff --git a/Downterm/Downterm/Models/HotkeyBinding.swift b/Downterm/Downterm/Models/HotkeyBinding.swift new file mode 100644 index 0000000..f484d7f --- /dev/null +++ b/Downterm/Downterm/Models/HotkeyBinding.swift @@ -0,0 +1,92 @@ +import AppKit +import Carbon.HIToolbox + +/// Serializable representation of a keyboard shortcut (modifier flags + key code). +/// Stored in UserDefaults as a JSON string. +struct HotkeyBinding: Codable, Equatable { + var modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt + var keyCode: UInt16 + + /// Checks whether the given NSEvent matches this binding. + func matches(_ event: NSEvent) -> Bool { + let mask = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option] + return mask.intersection(relevantFlags).rawValue == modifiers + && event.keyCode == keyCode + } + + /// Human-readable label like "⌘⏎" or "⌘⇧T". + var displayString: String { + var parts: [String] = [] + let flags = NSEvent.ModifierFlags(rawValue: modifiers) + if flags.contains(.control) { parts.append("⌃") } + if flags.contains(.option) { parts.append("⌥") } + if flags.contains(.shift) { parts.append("⇧") } + if flags.contains(.command) { parts.append("⌘") } + parts.append(keyName) + return parts.joined() + } + + private var keyName: String { + switch keyCode { + case 36: return "⏎" + case 48: return "⇥" + case 49: return "Space" + case 51: return "⌫" + case 53: return "⎋" + case 123: return "←" + case 124: return "→" + case 125: return "↓" + case 126: return "↑" + default: + // Try to get the character from the key code + let src = TISCopyCurrentKeyboardInputSource().takeRetainedValue() + let layoutDataRef = TISGetInputSourceProperty(src, kTISPropertyUnicodeKeyLayoutData) + if let layoutDataRef = layoutDataRef { + let layoutData = unsafeBitCast(layoutDataRef, to: CFData.self) as Data + var deadKeyState: UInt32 = 0 + var length = 0 + var chars = [UniChar](repeating: 0, count: 4) + layoutData.withUnsafeBytes { ptr in + let layoutPtr = ptr.baseAddress!.assumingMemoryBound(to: UCKeyboardLayout.self) + UCKeyTranslate( + layoutPtr, + keyCode, + UInt16(kUCKeyActionDisplay), + 0, // no modifiers for the base character + UInt32(LMGetKbdType()), + UInt32(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + 4, + &length, + &chars + ) + } + if length > 0 { + return String(utf16CodeUnits: chars, count: length).uppercased() + } + } + return "Key\(keyCode)" + } + } + + // MARK: - Serialization + + func toJSON() -> String { + (try? String(data: JSONEncoder().encode(self), encoding: .utf8)) ?? "{}" + } + + static func fromJSON(_ string: String) -> HotkeyBinding? { + guard let data = string.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(HotkeyBinding.self, from: data) + } + + // MARK: - Presets + + static let cmdReturn = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 36) + static let cmdT = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 17) + static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13) + static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ] + static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [ + static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2) +} diff --git a/Downterm/Downterm/Models/NotchSettings.swift b/Downterm/Downterm/Models/NotchSettings.swift new file mode 100644 index 0000000..869ecbc --- /dev/null +++ b/Downterm/Downterm/Models/NotchSettings.swift @@ -0,0 +1,170 @@ +import Foundation + +/// Central registry of all user-configurable notch settings. +enum NotchSettings { + + enum Keys { + // General + static let showOnAllDisplays = "showOnAllDisplays" + static let openNotchOnHover = "openNotchOnHover" + static let minimumHoverDuration = "minimumHoverDuration" + static let showMenuBarIcon = "showMenuBarIcon" + + // Sizing — closed state + static let notchHeight = "notchHeight" + static let nonNotchHeight = "nonNotchHeight" + static let notchHeightMode = "notchHeightMode" + static let nonNotchHeightMode = "nonNotchHeightMode" + + // Sizing — open state + static let openWidth = "openWidth" + static let openHeight = "openHeight" + + // Appearance + static let enableShadow = "enableShadow" + static let shadowRadius = "shadowRadius" + static let shadowOpacity = "shadowOpacity" + static let cornerRadiusScaling = "cornerRadiusScaling" + static let notchOpacity = "notchOpacity" + static let blurRadius = "blurRadius" + + // Animation + static let openSpringResponse = "openSpringResponse" + static let openSpringDamping = "openSpringDamping" + static let closeSpringResponse = "closeSpringResponse" + static let closeSpringDamping = "closeSpringDamping" + static let hoverSpringResponse = "hoverSpringResponse" + static let hoverSpringDamping = "hoverSpringDamping" + + // Behavior + static let enableGestures = "enableGestures" + static let gestureSensitivity = "gestureSensitivity" + + // Terminal + static let terminalFontSize = "terminalFontSize" + static let terminalShell = "terminalShell" + + // Hotkeys — each stores a HotkeyBinding JSON string + static let hotkeyToggle = "hotkey_toggle" + static let hotkeyNewTab = "hotkey_newTab" + static let hotkeyCloseTab = "hotkey_closeTab" + static let hotkeyNextTab = "hotkey_nextTab" + static let hotkeyPreviousTab = "hotkey_previousTab" + static let hotkeyDetachTab = "hotkey_detachTab" + } + + enum Defaults { + static let showOnAllDisplays: Bool = true + static let openNotchOnHover: Bool = true + static let minimumHoverDuration: Double = 0.3 + static let showMenuBarIcon: Bool = true + + static let notchHeight: Double = 32 + static let nonNotchHeight: Double = 32 + static let notchHeightMode: Int = 0 + static let nonNotchHeightMode: Int = 1 + + static let openWidth: Double = 640 + static let openHeight: Double = 350 + + static let enableShadow: Bool = true + static let shadowRadius: Double = 6 + static let shadowOpacity: Double = 0.5 + static let cornerRadiusScaling: Bool = true + static let notchOpacity: Double = 1.0 + static let blurRadius: Double = 0 + + static let openSpringResponse: Double = 0.42 + static let openSpringDamping: Double = 0.8 + static let closeSpringResponse: Double = 0.45 + static let closeSpringDamping: Double = 1.0 + static let hoverSpringResponse: Double = 0.38 + static let hoverSpringDamping: Double = 0.8 + + static let enableGestures: Bool = true + static let gestureSensitivity: Double = 0.5 + + static let terminalFontSize: Double = 13 + static let terminalShell: String = "" + + // Default hotkey bindings as JSON + static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON() + static let hotkeyNewTab: String = HotkeyBinding.cmdT.toJSON() + static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON() + static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON() + static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON() + static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON() + } + + static func registerDefaults() { + UserDefaults.standard.register(defaults: [ + Keys.showOnAllDisplays: Defaults.showOnAllDisplays, + Keys.openNotchOnHover: Defaults.openNotchOnHover, + Keys.minimumHoverDuration: Defaults.minimumHoverDuration, + Keys.showMenuBarIcon: Defaults.showMenuBarIcon, + + Keys.notchHeight: Defaults.notchHeight, + Keys.nonNotchHeight: Defaults.nonNotchHeight, + Keys.notchHeightMode: Defaults.notchHeightMode, + Keys.nonNotchHeightMode: Defaults.nonNotchHeightMode, + + Keys.openWidth: Defaults.openWidth, + Keys.openHeight: Defaults.openHeight, + + Keys.enableShadow: Defaults.enableShadow, + Keys.shadowRadius: Defaults.shadowRadius, + Keys.shadowOpacity: Defaults.shadowOpacity, + Keys.cornerRadiusScaling: Defaults.cornerRadiusScaling, + Keys.notchOpacity: Defaults.notchOpacity, + Keys.blurRadius: Defaults.blurRadius, + + Keys.openSpringResponse: Defaults.openSpringResponse, + Keys.openSpringDamping: Defaults.openSpringDamping, + Keys.closeSpringResponse: Defaults.closeSpringResponse, + Keys.closeSpringDamping: Defaults.closeSpringDamping, + Keys.hoverSpringResponse: Defaults.hoverSpringResponse, + Keys.hoverSpringDamping: Defaults.hoverSpringDamping, + + Keys.enableGestures: Defaults.enableGestures, + Keys.gestureSensitivity: Defaults.gestureSensitivity, + + Keys.terminalFontSize: Defaults.terminalFontSize, + Keys.terminalShell: Defaults.terminalShell, + + Keys.hotkeyToggle: Defaults.hotkeyToggle, + Keys.hotkeyNewTab: Defaults.hotkeyNewTab, + Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab, + Keys.hotkeyNextTab: Defaults.hotkeyNextTab, + Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab, + Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab, + ]) + } +} + +enum NotchHeightMode: Int, CaseIterable, Identifiable { + case matchRealNotchSize = 0 + case matchMenuBar = 1 + case custom = 2 + + var id: Int { rawValue } + var label: String { + switch self { + case .matchRealNotchSize: return "Match Notch" + case .matchMenuBar: return "Match Menu Bar" + case .custom: return "Custom" + } + } +} + +enum NonNotchHeightMode: Int, CaseIterable, Identifiable { + case matchMenuBar = 1 + case custom = 2 + + var id: Int { rawValue } + var label: String { + switch self { + case .matchMenuBar: return "Match Menu Bar" + case .custom: return "Custom" + } + } +} diff --git a/Downterm/Downterm/Models/NotchState.swift b/Downterm/Downterm/Models/NotchState.swift new file mode 100644 index 0000000..340146c --- /dev/null +++ b/Downterm/Downterm/Models/NotchState.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Represents the two visual states of the notch overlay. +enum NotchState: String { + /// Compact bar matching the physical notch or menu bar height. + case closed + + /// Expanded panel showing content (plain black for now). + case open +} diff --git a/Downterm/Downterm/Models/NotchViewModel.swift b/Downterm/Downterm/Models/NotchViewModel.swift new file mode 100644 index 0000000..621e822 --- /dev/null +++ b/Downterm/Downterm/Models/NotchViewModel.swift @@ -0,0 +1,66 @@ +import SwiftUI +import Combine + +/// Per-screen observable state that drives the notch UI. +@MainActor +class NotchViewModel: ObservableObject { + + let screenUUID: String + + @Published var notchState: NotchState = .closed + @Published var notchSize: CGSize + @Published var closedNotchSize: CGSize + @Published var isHovering: Bool = false + + let terminalManager = TerminalManager.shared + + /// Set by ScreenManager — routes open/close through proper + /// window activation so the terminal receives keyboard input. + var requestOpen: (() -> Void)? + var requestClose: (() -> Void)? + + private var cancellables = Set() + + @AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth + @AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight + + @AppStorage(NotchSettings.Keys.openSpringResponse) private var openSpringResponse = NotchSettings.Defaults.openSpringResponse + @AppStorage(NotchSettings.Keys.openSpringDamping) private var openSpringDamping = NotchSettings.Defaults.openSpringDamping + @AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeSpringResponse = NotchSettings.Defaults.closeSpringResponse + @AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeSpringDamping = NotchSettings.Defaults.closeSpringDamping + + var openAnimation: Animation { + .spring(response: openSpringResponse, dampingFraction: openSpringDamping) + } + var closeAnimation: Animation { + .spring(response: closeSpringResponse, dampingFraction: closeSpringDamping) + } + + init(screenUUID: String) { + self.screenUUID = screenUUID + let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main + let closed = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32) + self.closedNotchSize = closed + self.notchSize = closed + } + + func open() { + notchSize = CGSize(width: openWidth, height: openHeight) + notchState = .open + } + + func close() { + refreshClosedSize() + notchSize = closedNotchSize + notchState = .closed + } + + func refreshClosedSize() { + let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main + closedNotchSize = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32) + } + + var openNotchSize: CGSize { + CGSize(width: openWidth, height: openHeight) + } +} diff --git a/Downterm/Downterm/Models/TerminalManager.swift b/Downterm/Downterm/Models/TerminalManager.swift new file mode 100644 index 0000000..1ac4a68 --- /dev/null +++ b/Downterm/Downterm/Models/TerminalManager.swift @@ -0,0 +1,107 @@ +import SwiftUI +import Combine + +/// Manages multiple terminal tabs. Singleton shared across all screens — +/// whichever notch is currently open displays these tabs. +@MainActor +class TerminalManager: ObservableObject { + + static let shared = TerminalManager() + + @Published var tabs: [TerminalSession] = [] + @Published var activeTabIndex: Int = 0 + + @AppStorage(NotchSettings.Keys.terminalFontSize) + private var fontSize: Double = NotchSettings.Defaults.terminalFontSize + + private var cancellables = Set() + + private init() { + newTab() + } + + // MARK: - Active tab + + var activeTab: TerminalSession? { + guard tabs.indices.contains(activeTabIndex) else { return nil } + return tabs[activeTabIndex] + } + + /// Short title for the closed notch bar — the active tab's process name. + var activeTitle: String { + activeTab?.title ?? "shell" + } + + // MARK: - Tab operations + + func newTab() { + let session = TerminalSession(fontSize: CGFloat(fontSize)) + + // Forward title changes to trigger view updates in this manager + session.$title + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + + tabs.append(session) + activeTabIndex = tabs.count - 1 + } + + func closeTab(at index: Int) { + guard tabs.indices.contains(index) else { return } + tabs[index].terminate() + tabs.remove(at: index) + + // Adjust active index + if tabs.isEmpty { + newTab() + } else if activeTabIndex >= tabs.count { + activeTabIndex = tabs.count - 1 + } + } + + func closeActiveTab() { + closeTab(at: activeTabIndex) + } + + func switchToTab(at index: Int) { + guard tabs.indices.contains(index) else { return } + activeTabIndex = index + } + + func nextTab() { + guard tabs.count > 1 else { return } + activeTabIndex = (activeTabIndex + 1) % tabs.count + } + + func previousTab() { + guard tabs.count > 1 else { return } + activeTabIndex = (activeTabIndex - 1 + tabs.count) % tabs.count + } + + /// Removes the tab at the given index and returns the session so it + /// can be hosted in a pop-out window. + func detachTab(at index: Int) -> TerminalSession? { + guard tabs.indices.contains(index) else { return nil } + let session = tabs.remove(at: index) + + if tabs.isEmpty { + newTab() + } else if activeTabIndex >= tabs.count { + activeTabIndex = tabs.count - 1 + } + + return session + } + + func detachActiveTab() -> TerminalSession? { + detachTab(at: activeTabIndex) + } + + /// Updates font size on all existing terminal sessions. + func updateAllFontSizes(_ size: CGFloat) { + for tab in tabs { + tab.updateFontSize(size) + } + } +} diff --git a/Downterm/Downterm/Models/TerminalSession.swift b/Downterm/Downterm/Models/TerminalSession.swift new file mode 100644 index 0000000..483495e --- /dev/null +++ b/Downterm/Downterm/Models/TerminalSession.swift @@ -0,0 +1,122 @@ +import AppKit +import SwiftTerm +import Combine + +/// Wraps a single SwiftTerm TerminalView + LocalProcess pair. +@MainActor +class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, TerminalViewDelegate { + + let id = UUID() + let terminalView: TerminalView + private var process: LocalProcess? + + @Published var title: String = "shell" + @Published var isRunning: Bool = true + @Published var currentDirectory: String? + + init(fontSize: CGFloat) { + terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300)) + super.init() + + terminalView.terminalDelegate = self + + // Solid black — matches every other element in the notch. + // The single `.opacity(notchOpacity)` on ContentView makes + // everything uniformly transparent. + terminalView.nativeBackgroundColor = .black + terminalView.nativeForegroundColor = .init(white: 0.9, alpha: 1.0) + + let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + terminalView.font = font + + startShell() + } + + // MARK: - Shell management + + private func startShell() { + let shellPath = resolveShell() + FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory()) + let proc = LocalProcess(delegate: self) + proc.startProcess(executable: shellPath) + process = proc + title = (shellPath as NSString).lastPathComponent + } + + private func resolveShell() -> String { + let custom = UserDefaults.standard.string(forKey: NotchSettings.Keys.terminalShell) ?? "" + if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) { + return custom + } + return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" + } + + func updateFontSize(_ size: CGFloat) { + terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + } + + func terminate() { + process?.terminate() + process = nil + isRunning = false + } + + // MARK: - LocalProcessDelegate + + nonisolated func processTerminated(_ source: LocalProcess, exitCode: Int32?) { + Task { @MainActor in self.isRunning = false } + } + + nonisolated func dataReceived(slice: ArraySlice) { + let data = slice + Task { @MainActor in self.terminalView.feed(byteArray: data) } + } + + nonisolated func getWindowSize() -> winsize { + var ws = winsize() + ws.ws_col = 80 + ws.ws_row = 24 + return ws + } + + // MARK: - TerminalViewDelegate + + func send(source: TerminalView, data: ArraySlice) { + process?.send(data: data) + } + + func setTerminalTitle(source: TerminalView, title: String) { + self.title = title.isEmpty ? "shell" : title + } + + func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { + guard newCols > 0, newRows > 0 else { return } + guard let proc = process else { return } + let fd = proc.childfd + guard fd >= 0 else { return } + + var ws = winsize() + ws.ws_col = UInt16(newCols) + ws.ws_row = UInt16(newRows) + _ = ioctl(fd, TIOCSWINSZ, &ws) + } + + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) { + currentDirectory = directory + } + + func scrolled(source: TerminalView, position: Double) {} + func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} + + func clipboardCopy(source: TerminalView, content: Data) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setData(content, forType: .string) + } + + func requestOpenLink(source: TerminalView, link: String, params: [String : String]) { + if let url = URL(string: link) { NSWorkspace.shared.open(url) } + } + + func bell(source: TerminalView) { NSSound.beep() } + func iTermContent(source: TerminalView, content: ArraySlice) {} +} diff --git a/Downterm/Downterm/Resources/Downterm.entitlements b/Downterm/Downterm/Resources/Downterm.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Downterm/Downterm/Resources/Downterm.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/Downterm/Downterm/Resources/Info.plist b/Downterm/Downterm/Resources/Info.plist new file mode 100644 index 0000000..4710e06 --- /dev/null +++ b/Downterm/Downterm/Resources/Info.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Downterm + CFBundleExecutable + Downterm + CFBundleIdentifier + com.downterm.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Downterm + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.2.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + LSUIElement + + NSHumanReadableCopyright + Copyright © 2026 Downterm. All rights reserved. + + diff --git a/Downterm/Downterm/Views/SettingsView.swift b/Downterm/Downterm/Views/SettingsView.swift new file mode 100644 index 0000000..3b8c2c5 --- /dev/null +++ b/Downterm/Downterm/Views/SettingsView.swift @@ -0,0 +1,380 @@ +import SwiftUI + +/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About. +struct SettingsView: View { + + @State private var selectedTab: SettingsTab = .general + + var body: some View { + NavigationSplitView { + List(SettingsTab.allCases, selection: $selectedTab) { tab in + Label(tab.label, systemImage: tab.icon) + .tag(tab) + } + .listStyle(.sidebar) + .navigationSplitViewColumnWidth(min: 180, ideal: 200) + } detail: { + ScrollView { + detailView.padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minWidth: 600, minHeight: 400) + } + + @ViewBuilder + private var detailView: some View { + switch selectedTab { + case .general: GeneralSettingsView() + case .appearance: AppearanceSettingsView() + case .animation: AnimationSettingsView() + case .terminal: TerminalSettingsView() + case .hotkeys: HotkeySettingsView() + case .about: AboutSettingsView() + } + } +} + +// MARK: - Tabs + +enum SettingsTab: String, CaseIterable, Identifiable { + case general, appearance, animation, terminal, hotkeys, about + + var id: String { rawValue } + + var label: String { + switch self { + case .general: return "General" + case .appearance: return "Appearance" + case .animation: return "Animation" + case .terminal: return "Terminal" + case .hotkeys: return "Hotkeys" + case .about: return "About" + } + } + + var icon: String { + switch self { + case .general: return "gearshape" + case .appearance: return "paintbrush" + case .animation: return "bolt.fill" + case .terminal: return "terminal" + case .hotkeys: return "keyboard" + case .about: return "info.circle" + } + } +} + +// MARK: - General + +struct GeneralSettingsView: View { + + @AppStorage(NotchSettings.Keys.showOnAllDisplays) private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays + @AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover + @AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration + @AppStorage(NotchSettings.Keys.showMenuBarIcon) private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon + @AppStorage(NotchSettings.Keys.enableGestures) private var enableGestures = NotchSettings.Defaults.enableGestures + @AppStorage(NotchSettings.Keys.gestureSensitivity) private var gestureSensitivity = NotchSettings.Defaults.gestureSensitivity + + @AppStorage(NotchSettings.Keys.notchHeightMode) private var notchHeightMode = NotchSettings.Defaults.notchHeightMode + @AppStorage(NotchSettings.Keys.notchHeight) private var notchHeight = NotchSettings.Defaults.notchHeight + @AppStorage(NotchSettings.Keys.nonNotchHeightMode) private var nonNotchHeightMode = NotchSettings.Defaults.nonNotchHeightMode + @AppStorage(NotchSettings.Keys.nonNotchHeight) private var nonNotchHeight = NotchSettings.Defaults.nonNotchHeight + + @AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth + @AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight + + var body: some View { + Form { + Section("Display") { + Toggle("Show on all displays", isOn: $showOnAllDisplays) + Toggle("Show menu bar icon", isOn: $showMenuBarIcon) + } + + Section("Hover Behavior") { + Toggle("Open notch on hover", isOn: $openNotchOnHover) + if openNotchOnHover { + HStack { + Text("Hover delay") + Slider(value: $minimumHoverDuration, in: 0.0...2.0, step: 0.05) + Text(String(format: "%.2fs", minimumHoverDuration)) + .monospacedDigit().frame(width: 50) + } + } + } + + Section("Gestures") { + Toggle("Enable gestures", isOn: $enableGestures) + if enableGestures { + HStack { + Text("Sensitivity") + Slider(value: $gestureSensitivity, in: 0.1...1.0, step: 0.05) + Text(String(format: "%.2f", gestureSensitivity)) + .monospacedDigit().frame(width: 50) + } + } + } + + Section("Closed Notch Size") { + Picker("Notch screens", selection: $notchHeightMode) { + ForEach(NotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) } + } + if notchHeightMode == NotchHeightMode.custom.rawValue { + HStack { + Text("Custom height") + Slider(value: $notchHeight, in: 16...64, step: 1) + Text("\(Int(notchHeight))pt").monospacedDigit().frame(width: 50) + } + } + Picker("Non-notch screens", selection: $nonNotchHeightMode) { + ForEach(NonNotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) } + } + if nonNotchHeightMode == NonNotchHeightMode.custom.rawValue { + HStack { + Text("Custom height") + Slider(value: $nonNotchHeight, in: 16...64, step: 1) + Text("\(Int(nonNotchHeight))pt").monospacedDigit().frame(width: 50) + } + } + } + + Section("Open Notch Size") { + HStack { + Text("Width") + Slider(value: $openWidth, in: 300...1200, step: 10) + Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60) + } + HStack { + Text("Height") + Slider(value: $openHeight, in: 100...600, step: 10) + Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60) + } + } + } + .formStyle(.grouped) + } +} + +// MARK: - Appearance + +struct AppearanceSettingsView: View { + + @AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow + @AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius + @AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity + @AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling + @AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity + @AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius + + var body: some View { + Form { + Section("Shadow") { + Toggle("Enable shadow", isOn: $enableShadow) + if enableShadow { + HStack { + Text("Radius") + Slider(value: $shadowRadius, in: 0...30, step: 1) + Text(String(format: "%.0f", shadowRadius)).monospacedDigit().frame(width: 40) + } + HStack { + Text("Opacity") + Slider(value: $shadowOpacity, in: 0...1, step: 0.05) + Text(String(format: "%.2f", shadowOpacity)).monospacedDigit().frame(width: 50) + } + } + } + Section("Shape") { + Toggle("Scale corner radii when open", isOn: $cornerRadiusScaling) + } + Section("Opacity & Blur") { + HStack { + Text("Notch opacity") + Slider(value: $notchOpacity, in: 0...1, step: 0.05) + Text(String(format: "%.2f", notchOpacity)).monospacedDigit().frame(width: 50) + } + HStack { + Text("Blur radius") + Slider(value: $blurRadius, in: 0...20, step: 0.5) + Text(String(format: "%.1f", blurRadius)).monospacedDigit().frame(width: 50) + } + } + } + .formStyle(.grouped) + } +} + +// MARK: - Animation + +struct AnimationSettingsView: View { + + @AppStorage(NotchSettings.Keys.openSpringResponse) private var openResponse = NotchSettings.Defaults.openSpringResponse + @AppStorage(NotchSettings.Keys.openSpringDamping) private var openDamping = NotchSettings.Defaults.openSpringDamping + @AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeResponse = NotchSettings.Defaults.closeSpringResponse + @AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping + @AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse + @AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping + + var body: some View { + Form { + Section("Open Animation") { + springControls(response: $openResponse, damping: $openDamping) + } + Section("Close Animation") { + springControls(response: $closeResponse, damping: $closeDamping) + } + Section("Hover Animation") { + springControls(response: $hoverResponse, damping: $hoverDamping) + } + Section { + Button("Reset to Defaults") { + openResponse = NotchSettings.Defaults.openSpringResponse + openDamping = NotchSettings.Defaults.openSpringDamping + closeResponse = NotchSettings.Defaults.closeSpringResponse + closeDamping = NotchSettings.Defaults.closeSpringDamping + hoverResponse = NotchSettings.Defaults.hoverSpringResponse + hoverDamping = NotchSettings.Defaults.hoverSpringDamping + } + } + } + .formStyle(.grouped) + } + + @ViewBuilder + private func springControls(response: Binding, damping: Binding) -> some View { + HStack { + Text("Response") + Slider(value: response, in: 0.1...1.5, step: 0.01) + Text(String(format: "%.2f", response.wrappedValue)).monospacedDigit().frame(width: 50) + } + HStack { + Text("Damping") + Slider(value: damping, in: 0.1...1.5, step: 0.01) + Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50) + } + } +} + +// MARK: - Terminal + +struct TerminalSettingsView: View { + + @AppStorage(NotchSettings.Keys.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize + @AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell + + var body: some View { + Form { + Section("Font") { + HStack { + Text("Font size") + Slider(value: $fontSize, in: 8...28, step: 1) + Text("\(Int(fontSize))pt").monospacedDigit().frame(width: 50) + } + } + Section("Shell") { + TextField("Shell path (empty = $SHELL)", text: $shellPath) + .textFieldStyle(.roundedBorder) + Text("Leave empty to use your default shell ($SHELL or /bin/zsh).") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + } +} + +// MARK: - Hotkeys + +struct HotkeySettingsView: View { + + @State private var toggleBinding = loadBinding(NotchSettings.Keys.hotkeyToggle, fallback: .cmdReturn) + @State private var newTabBinding = loadBinding(NotchSettings.Keys.hotkeyNewTab, fallback: .cmdT) + @State private var closeTabBinding = loadBinding(NotchSettings.Keys.hotkeyCloseTab, fallback: .cmdW) + @State private var nextTabBinding = loadBinding(NotchSettings.Keys.hotkeyNextTab, fallback: .cmdShiftRB) + @State private var prevTabBinding = loadBinding(NotchSettings.Keys.hotkeyPreviousTab, fallback: .cmdShiftLB) + @State private var detachBinding = loadBinding(NotchSettings.Keys.hotkeyDetachTab, fallback: .cmdD) + + var body: some View { + Form { + Section("Global") { + HotkeyRecorderView(label: "Toggle notch", binding: bindAndSave($toggleBinding, key: NotchSettings.Keys.hotkeyToggle)) + } + + Section("Terminal Tabs (active when notch is open)") { + HotkeyRecorderView(label: "New tab", binding: bindAndSave($newTabBinding, key: NotchSettings.Keys.hotkeyNewTab)) + HotkeyRecorderView(label: "Close tab", binding: bindAndSave($closeTabBinding, key: NotchSettings.Keys.hotkeyCloseTab)) + HotkeyRecorderView(label: "Next tab", binding: bindAndSave($nextTabBinding, key: NotchSettings.Keys.hotkeyNextTab)) + HotkeyRecorderView(label: "Previous tab", binding: bindAndSave($prevTabBinding, key: NotchSettings.Keys.hotkeyPreviousTab)) + HotkeyRecorderView(label: "Detach tab", binding: bindAndSave($detachBinding, key: NotchSettings.Keys.hotkeyDetachTab)) + } + + Section { + Text("⌘1–9 always switch to tab by number.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + Button("Reset to Defaults") { + toggleBinding = .cmdReturn + newTabBinding = .cmdT + closeTabBinding = .cmdW + nextTabBinding = .cmdShiftRB + prevTabBinding = .cmdShiftLB + detachBinding = .cmdD + + save(.cmdReturn, key: NotchSettings.Keys.hotkeyToggle) + save(.cmdT, key: NotchSettings.Keys.hotkeyNewTab) + save(.cmdW, key: NotchSettings.Keys.hotkeyCloseTab) + save(.cmdShiftRB, key: NotchSettings.Keys.hotkeyNextTab) + save(.cmdShiftLB, key: NotchSettings.Keys.hotkeyPreviousTab) + save(.cmdD, key: NotchSettings.Keys.hotkeyDetachTab) + } + } + } + .formStyle(.grouped) + } + + /// Creates a binding that saves to UserDefaults on every change. + private func bindAndSave(_ state: Binding, key: String) -> Binding { + Binding( + get: { state.wrappedValue }, + set: { newValue in + state.wrappedValue = newValue + save(newValue, key: key) + } + ) + } + + private func save(_ binding: HotkeyBinding, key: String) { + UserDefaults.standard.set(binding.toJSON(), forKey: key) + } + + private static func loadBinding(_ key: String, fallback: HotkeyBinding) -> HotkeyBinding { + guard let json = UserDefaults.standard.string(forKey: key), + let b = HotkeyBinding.fromJSON(json) else { return fallback } + return b + } +} + +// MARK: - About + +struct AboutSettingsView: View { + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "terminal") + .font(.system(size: 64)) + .foregroundStyle(.secondary) + Text("Downterm") + .font(.largeTitle.bold()) + Text("Version 0.3.0") + .foregroundStyle(.secondary) + Text("A drop-down terminal that lives in your notch.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity) + .padding(.top, 40) + } +} diff --git a/Downterm/project.yml b/Downterm/project.yml new file mode 100644 index 0000000..7fc8652 --- /dev/null +++ b/Downterm/project.yml @@ -0,0 +1,48 @@ +name: Downterm +options: + bundleIdPrefix: com.downterm + deploymentTarget: + macOS: "14.0" + xcodeVersion: "16.0" + generateEmptyDirectories: true +settings: + base: + SWIFT_VERSION: "5.10" + MACOSX_DEPLOYMENT_TARGET: "14.0" + ENABLE_HARDENED_RUNTIME: true +packages: + SwiftTerm: + url: https://github.com/migueldeicaza/SwiftTerm.git + from: "1.2.0" +targets: + Downterm: + type: application + platform: macOS + sources: + - path: Downterm + excludes: + - Resources/Info.plist + dependencies: + - package: SwiftTerm + info: + path: Downterm/Resources/Info.plist + properties: + CFBundleName: Downterm + CFBundleDisplayName: Downterm + CFBundleIdentifier: com.downterm.app + CFBundleVersion: "1" + CFBundleShortVersionString: "0.2.0" + CFBundlePackageType: APPL + CFBundleExecutable: Downterm + LSMinimumSystemVersion: "14.0" + LSUIElement: true + NSHumanReadableCopyright: "Copyright © 2026 Downterm. All rights reserved." + entitlements: + path: Downterm/Resources/Downterm.entitlements + settings: + base: + CODE_SIGN_ENTITLEMENTS: Downterm/Resources/Downterm.entitlements + INFOPLIST_FILE: Downterm/Resources/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: com.downterm.app + PRODUCT_NAME: Downterm + COMBINE_HIDPI_IMAGES: true