From 4070904db8f95e99eab47827d3bf4627d3736232 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Fri, 27 Feb 2026 11:57:09 +1100 Subject: [PATCH] Initial commit --- .gitignore | 80 ++++ Downterm/Downterm.xcodeproj/project.pbxproj | 444 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 24 + .../UserInterfaceState.xcuserstate | Bin 0 -> 24178 bytes .../xcschemes/xcschememanagement.plist | 14 + Downterm/Downterm/AppDelegate.swift | 69 +++ .../Components/HotkeyRecorderView.swift | 111 +++++ Downterm/Downterm/Components/NotchShape.swift | 109 +++++ .../Downterm/Components/NotchWindow.swift | 78 +++ .../Downterm/Components/SwiftTermView.swift | 46 ++ Downterm/Downterm/Components/TabBar.swift | 73 +++ Downterm/Downterm/ContentView.swift | 166 +++++++ Downterm/Downterm/DowntermApp.swift | 36 ++ .../Extensions/NSScreen+Extensions.swift | 85 ++++ .../Downterm/Managers/HotkeyManager.swift | 241 ++++++++++ .../Managers/PopoutWindowController.swift | 73 +++ .../Downterm/Managers/ScreenManager.swift | 250 ++++++++++ .../Managers/SettingsWindowController.swift | 58 +++ Downterm/Downterm/Models/HotkeyBinding.swift | 92 ++++ Downterm/Downterm/Models/NotchSettings.swift | 170 +++++++ Downterm/Downterm/Models/NotchState.swift | 10 + Downterm/Downterm/Models/NotchViewModel.swift | 66 +++ .../Downterm/Models/TerminalManager.swift | 107 +++++ .../Downterm/Models/TerminalSession.swift | 122 +++++ .../Downterm/Resources/Downterm.entitlements | 5 + Downterm/Downterm/Resources/Info.plist | 30 ++ Downterm/Downterm/Views/SettingsView.swift | 380 +++++++++++++++ Downterm/project.yml | 48 ++ 29 files changed, 2994 insertions(+) create mode 100644 .gitignore create mode 100644 Downterm/Downterm.xcodeproj/project.pbxproj create mode 100644 Downterm/Downterm.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Downterm/Downterm.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Downterm/Downterm.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 Downterm/Downterm.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 Downterm/Downterm/AppDelegate.swift create mode 100644 Downterm/Downterm/Components/HotkeyRecorderView.swift create mode 100644 Downterm/Downterm/Components/NotchShape.swift create mode 100644 Downterm/Downterm/Components/NotchWindow.swift create mode 100644 Downterm/Downterm/Components/SwiftTermView.swift create mode 100644 Downterm/Downterm/Components/TabBar.swift create mode 100644 Downterm/Downterm/ContentView.swift create mode 100644 Downterm/Downterm/DowntermApp.swift create mode 100644 Downterm/Downterm/Extensions/NSScreen+Extensions.swift create mode 100644 Downterm/Downterm/Managers/HotkeyManager.swift create mode 100644 Downterm/Downterm/Managers/PopoutWindowController.swift create mode 100644 Downterm/Downterm/Managers/ScreenManager.swift create mode 100644 Downterm/Downterm/Managers/SettingsWindowController.swift create mode 100644 Downterm/Downterm/Models/HotkeyBinding.swift create mode 100644 Downterm/Downterm/Models/NotchSettings.swift create mode 100644 Downterm/Downterm/Models/NotchState.swift create mode 100644 Downterm/Downterm/Models/NotchViewModel.swift create mode 100644 Downterm/Downterm/Models/TerminalManager.swift create mode 100644 Downterm/Downterm/Models/TerminalSession.swift create mode 100644 Downterm/Downterm/Resources/Downterm.entitlements create mode 100644 Downterm/Downterm/Resources/Info.plist create mode 100644 Downterm/Downterm/Views/SettingsView.swift create mode 100644 Downterm/project.yml 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 0000000000000000000000000000000000000000..86a7023e22120bda65a53265dba7be933954ec52 GIT binary patch literal 24178 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 mp>k*Cp30{y4^esbu=ZoztFaAG8rOE>U literal 0 HcmV?d00001 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