Initial commit
This commit is contained in:
80
.gitignore
vendored
Normal file
80
.gitignore
vendored
Normal file
@@ -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/
|
||||||
444
Downterm/Downterm.xcodeproj/project.pbxproj
Normal file
444
Downterm/Downterm.xcodeproj/project.pbxproj
Normal file
@@ -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 = "<group>"; };
|
||||||
|
0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
15A290D4D21D6C01A583A372 /* ScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenManager.swift; sourceTree = "<group>"; };
|
||||||
|
1E47000112562615C7E59489 /* SwiftTermView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTermView.swift; sourceTree = "<group>"; };
|
||||||
|
1FC09C538CBE7C2D072008B2 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = "<group>"; };
|
||||||
|
20BA7F4716DA3909DA8BC381 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
2C5C99B7CD7F60E55844E40C /* NotchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchState.swift; sourceTree = "<group>"; };
|
||||||
|
3B72743F178231E0B06DD3DE /* HotkeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyManager.swift; sourceTree = "<group>"; };
|
||||||
|
490C53139360D970099D8F3D /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = "<group>"; };
|
||||||
|
4B671125208055E5334CB85E /* DowntermApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DowntermApp.swift; sourceTree = "<group>"; };
|
||||||
|
4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyBinding.swift; sourceTree = "<group>"; };
|
||||||
|
589421631401C819FE1A7BA9 /* NotchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
5C0779490DE9020FBBC464BE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
9547A79F60E46F4521A70674 /* Downterm.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Downterm.entitlements; sourceTree = "<group>"; };
|
||||||
|
AA6359CF9DDF89413440300D /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = "<group>"; };
|
||||||
|
BA6843B571B41986DE386F5F /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
|
||||||
|
C5CB3313B230019D0E988AFE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoutWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
F009B75D078A5070B5EA9738 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
|
||||||
|
F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
27C90448ECAC906F0DA429C0 /* Managers */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3B72743F178231E0B06DD3DE /* HotkeyManager.swift */,
|
||||||
|
EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */,
|
||||||
|
15A290D4D21D6C01A583A372 /* ScreenManager.swift */,
|
||||||
|
0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */,
|
||||||
|
);
|
||||||
|
path = Managers;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
792DD4F8C079680683D8FF7A /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
665CFC051CF185B71199608D /* Downterm.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
8D95E0324E6AFC9E4DC0C087 /* Extensions */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */,
|
||||||
|
);
|
||||||
|
path = Extensions;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
C2B8955F4D0A1DAA7E60326A /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C5CB3313B230019D0E988AFE /* SettingsView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F32F526005A2589010E63C76 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
490C53139360D970099D8F3D /* HotkeyRecorderView.swift */,
|
||||||
|
1FC09C538CBE7C2D072008B2 /* NotchShape.swift */,
|
||||||
|
02FEFF9074A85F02C43D9408 /* NotchWindow.swift */,
|
||||||
|
1E47000112562615C7E59489 /* SwiftTermView.swift */,
|
||||||
|
F009B75D078A5070B5EA9738 /* TabBar.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
FC6F23514BFE2235BD4154E8 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9E1CA4816F67033BBD52D8A3 /* Downterm */,
|
||||||
|
792DD4F8C079680683D8FF7A /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
7
Downterm/Downterm.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Downterm/Downterm.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>Downterm.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
69
Downterm/Downterm/AppDelegate.swift
Normal file
69
Downterm/Downterm/AppDelegate.swift
Normal file
@@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
Downterm/Downterm/Components/HotkeyRecorderView.swift
Normal file
111
Downterm/Downterm/Components/HotkeyRecorderView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
109
Downterm/Downterm/Components/NotchShape.swift
Normal file
109
Downterm/Downterm/Components/NotchShape.swift
Normal file
@@ -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<CGFloat, CGFloat> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
Downterm/Downterm/Components/NotchWindow.swift
Normal file
78
Downterm/Downterm/Components/NotchWindow.swift
Normal file
@@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
Downterm/Downterm/Components/SwiftTermView.swift
Normal file
46
Downterm/Downterm/Components/SwiftTermView.swift
Normal file
@@ -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),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Downterm/Downterm/Components/TabBar.swift
Normal file
73
Downterm/Downterm/Components/TabBar.swift
Normal file
@@ -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)) + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
166
Downterm/Downterm/ContentView.swift
Normal file
166
Downterm/Downterm/ContentView.swift
Normal file
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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)) + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
Downterm/Downterm/DowntermApp.swift
Normal file
36
Downterm/Downterm/DowntermApp.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Downterm/Downterm/Extensions/NSScreen+Extensions.swift
Normal file
85
Downterm/Downterm/Extensions/NSScreen+Extensions.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
241
Downterm/Downterm/Managers/HotkeyManager.swift
Normal file
241
Downterm/Downterm/Managers/HotkeyManager.swift
Normal file
@@ -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<EventHotKeyID>.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
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Downterm/Downterm/Managers/PopoutWindowController.swift
Normal file
73
Downterm/Downterm/Managers/PopoutWindowController.swift
Normal file
@@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
// 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
|
||||||
250
Downterm/Downterm/Managers/ScreenManager.swift
Normal file
250
Downterm/Downterm/Managers/ScreenManager.swift
Normal file
@@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Downterm/Downterm/Managers/SettingsWindowController.swift
Normal file
58
Downterm/Downterm/Managers/SettingsWindowController.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
92
Downterm/Downterm/Models/HotkeyBinding.swift
Normal file
92
Downterm/Downterm/Models/HotkeyBinding.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
170
Downterm/Downterm/Models/NotchSettings.swift
Normal file
170
Downterm/Downterm/Models/NotchSettings.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Downterm/Downterm/Models/NotchState.swift
Normal file
10
Downterm/Downterm/Models/NotchState.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
66
Downterm/Downterm/Models/NotchViewModel.swift
Normal file
66
Downterm/Downterm/Models/NotchViewModel.swift
Normal file
@@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
107
Downterm/Downterm/Models/TerminalManager.swift
Normal file
107
Downterm/Downterm/Models/TerminalManager.swift
Normal file
@@ -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<AnyCancellable>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
Downterm/Downterm/Models/TerminalSession.swift
Normal file
122
Downterm/Downterm/Models/TerminalSession.swift
Normal file
@@ -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<UInt8>) {
|
||||||
|
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<UInt8>) {
|
||||||
|
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<UInt8>) {}
|
||||||
|
}
|
||||||
5
Downterm/Downterm/Resources/Downterm.entitlements
Normal file
5
Downterm/Downterm/Resources/Downterm.entitlements
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
30
Downterm/Downterm/Resources/Info.plist
Normal file
30
Downterm/Downterm/Resources/Info.plist
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Downterm</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>Downterm</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.downterm.app</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Downterm</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.2.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>14.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Copyright © 2026 Downterm. All rights reserved.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
380
Downterm/Downterm/Views/SettingsView.swift
Normal file
380
Downterm/Downterm/Views/SettingsView.swift
Normal file
@@ -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<Double>, damping: Binding<Double>) -> 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<HotkeyBinding>, key: String) -> Binding<HotkeyBinding> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Downterm/project.yml
Normal file
48
Downterm/project.yml
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user