File system cleanup

This commit is contained in:
2026-03-13 21:26:06 +11:00
parent 8ecb7d4382
commit cf3dba8fe4
83 changed files with 231 additions and 3 deletions

View File

@@ -0,0 +1,787 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
088FE22B3308AAB25027E086 /* NotchShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F57837A7115DEEE11E14B40 /* NotchShape.swift */; };
0DE8E7F29FBAA7E2B613E221 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3C8C11834931A9D53BC2B6 /* AppearanceSettingsView.swift */; };
0F133E8A88D2E313D90C32AD /* WindowFrameCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */; };
12F68EDA880030DAB644FF5F /* LaunchAtLoginHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8210D7783A614FD7190F5DDD /* LaunchAtLoginHelper.swift */; };
154F363D434A26105C5999B5 /* WorkspaceRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */; };
187F4B521BFC3BD29ADA79E3 /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */; };
1AB4A0F1BE668D3130EFBA93 /* TerminalTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */; };
2089566A2BBAA65EA82119B3 /* NotchOrchestratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */; };
2375B9DA559A0777FE558A8B /* WorkspaceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */; };
23E2DDCF36D0DAB2EA72C39C /* NotchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */; };
26AE379040149CBE05B314BB /* WorkspacesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */; };
278010607B0D552DCC8996C5 /* CommandNotchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726B935606FD961FD7E8C2BE /* CommandNotchUITests.swift */; };
2DF22798D3A7514E2A9183FC /* WorkspaceSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */; };
34AF69BF7AF8DC78ADE3774A /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */; };
3B69CB3CDEC2E5F2DCE600F9 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */; };
4CFA0C79095ACE0A327A2469 /* WorkspaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */; };
4D335F67B71F7DD977B6AEF9 /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6C98C406899F4B242075AF /* AppSettingsStore.swift */; };
4E0AD14B7427532271E485AA /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */; };
507A67E770DEFAF5BC321FCF /* WindowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9220E02B6D470DD05CA540C /* WindowCoordinator.swift */; };
5379DF2FACE924BDDB584377 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = C921E6435A64AA07A0FEA4D5 /* SwiftTerm */; };
5A3FDFCF30A1AAFE070290E9 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 728B3125F7F7FDB7313D2DC6 /* SettingsWindowController.swift */; };
5F3534F66A5DBE4E081AFFA6 /* SwiftTermView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */; };
65C7DB7296C6C6A77598A1F4 /* TerminalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */; };
6624A60CB121A22A03365071 /* WorkspaceSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */; };
6A18F6635B509FF58669F505 /* HotkeySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */; };
6DD4DAE72E5C3858B230D94C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A8DCFA77626F9999B432FE /* AppDelegate.swift */; };
6F249EDFA2D654457DF385F1 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567E85A2ED628460CEC760DB /* TerminalManager.swift */; };
771088361F981A9AAE976F3C /* SettingsBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 900F0476BE9E3600FBD371BB /* SettingsBindings.swift */; };
787477BB6D2F8AC723EEDA7D /* ScreenContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7181BB1F3926B457445105E5 /* ScreenContext.swift */; };
7A69B5AAC686174BCA54D0F0 /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E1791BB45E1505500ACC67 /* TerminalSession.swift */; };
7A7D3455D50D041CF698E786 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74463E4EAB78F56345360CD5 /* AppSettings.swift */; };
7AB14019B5CC6ED84B96FA47 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6ADD641A088A5005DD0EB4A6 /* Assets.xcassets */; };
7D5F3C3B5E173B6F06901812 /* AppSettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39CA6F2C2CA32D0AA58F6C43 /* AppSettingsStoreTests.swift */; };
86BB4A41E8EC44A5F45CE995 /* WorkspaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */; };
88113BA9B217DA579C36BEBE /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72A1D3D12BAC593838B3125C /* TabBar.swift */; };
88D87155D79C493D8956AA3B /* AnimationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE99DBAB656EA80A117D2EE1 /* AnimationSettingsView.swift */; };
8A1B2A4CD61B0D9A4BAB075B /* ScreenRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */; };
9F535B9516A4AE9FB536B1BD /* HotkeyBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB1C403BC2157756F572ACF /* HotkeyBinding.swift */; };
A74E14B8AD19C53820853D8E /* NSScreen+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDC7451F0E7ACFE0BEC5473 /* NSScreen+Extensions.swift */; };
AE8215538D4B026A00BAD241 /* WorkspaceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B957BD1F6120D2592613ED /* WorkspaceStore.swift */; };
B406183CC7B5DCE76989A066 /* AppSettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB28950392C0198E69F3564B /* AppSettingsController.swift */; };
B605A9311557251A85183383 /* AboutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */; };
C84FA3100884649FE92BF5DD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64A11F27E65B342B991629A /* ContentView.swift */; };
CFBBE994BA6BAD4658AAB9CB /* TerminalCommandArrowBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */; };
D088BC850F37E717844761C6 /* CommandNotchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5159CB9DBE2BAA0D2E201C39 /* CommandNotchApp.swift */; };
D2468B19D6F0A2C1DFDFE2B7 /* ScreenContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */; };
D4CEB4B895F75D91FA892A06 /* PopoutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */; };
DAD3AB4A0DAADA32C02D959E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3125FD3DC55420122CF85D80 /* SettingsView.swift */; };
DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */; };
E63FD5862C0EC54E284F6A0F /* ScreenRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */; };
E792411FA82E79E810F4B4C3 /* HotkeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */; };
EE72479BA5A25FF31BACCC50 /* NotchOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */; };
F8ED522FDC96B1F9AD8933F7 /* ScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB8AF76C0F728897A26D7EF /* ScreenManager.swift */; };
F8F8DE2F26608259D72635B7 /* AppSettingsControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
13E720E97D079D298D124BBE /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = E99C2EDB39F7B64C1540BCA8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D5585E5732CD067DF6EF0C69;
remoteInfo = CommandNotch;
};
9D6387038E18398CA764147F /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = E99C2EDB39F7B64C1540BCA8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D5585E5732CD067DF6EF0C69;
remoteInfo = CommandNotch;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistry.swift; sourceTree = "<group>"; };
27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = "<group>"; };
297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = "<group>"; };
2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = "<group>"; };
3125FD3DC55420122CF85D80 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
35CBC14E11EBD8486457CE91 /* CommandNotchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CommandNotchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
39CA6F2C2CA32D0AA58F6C43 /* AppSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStoreTests.swift; sourceTree = "<group>"; };
3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceController.swift; sourceTree = "<group>"; };
3F57837A7115DEEE11E14B40 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = "<group>"; };
3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsControllerTests.swift; sourceTree = "<group>"; };
48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = "<group>"; };
496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CommandNotchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
49E1791BB45E1505500ACC67 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = "<group>"; };
4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchState.swift; sourceTree = "<group>"; };
4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStoreTests.swift; sourceTree = "<group>"; };
5159CB9DBE2BAA0D2E201C39 /* CommandNotchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchApp.swift; sourceTree = "<group>"; };
567E85A2ED628460CEC760DB /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistryTests.swift; sourceTree = "<group>"; };
5DDC7451F0E7ACFE0BEC5473 /* NSScreen+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+Extensions.swift"; sourceTree = "<group>"; };
6ADD641A088A5005DD0EB4A6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
7181BB1F3926B457445105E5 /* ScreenContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenContext.swift; sourceTree = "<group>"; };
726B935606FD961FD7E8C2BE /* CommandNotchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchUITests.swift; sourceTree = "<group>"; };
728B3125F7F7FDB7313D2DC6 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
72A1D3D12BAC593838B3125C /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
74463E4EAB78F56345360CD5 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSummary.swift; sourceTree = "<group>"; };
8210D7783A614FD7190F5DDD /* LaunchAtLoginHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginHelper.swift; sourceTree = "<group>"; };
8796768C84519077354A95C7 /* CommandNotch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CommandNotch.entitlements; sourceTree = "<group>"; };
8B3C8C11834931A9D53BC2B6 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = "<group>"; };
8BB1C403BC2157756F572ACF /* HotkeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyBinding.swift; sourceTree = "<group>"; };
8CB8AF76C0F728897A26D7EF /* ScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenManager.swift; sourceTree = "<group>"; };
900F0476BE9E3600FBD371BB /* SettingsBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBindings.swift; sourceTree = "<group>"; };
9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTermView.swift; sourceTree = "<group>"; };
9E6C98C406899F4B242075AF /* AppSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStore.swift; sourceTree = "<group>"; };
A64A11F27E65B342B991629A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenContextTests.swift; sourceTree = "<group>"; };
AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenRegistry.swift; sourceTree = "<group>"; };
B5AA68DC0DD3FE07B56EB6EC /* CommandNotch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CommandNotch.app; sourceTree = BUILT_PRODUCTS_DIR; };
C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCommandArrowBehaviorTests.swift; sourceTree = "<group>"; };
CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoutWindowController.swift; sourceTree = "<group>"; };
CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTheme.swift; sourceTree = "<group>"; };
D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeySettingsView.swift; sourceTree = "<group>"; };
D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrameCalculatorTests.swift; sourceTree = "<group>"; };
DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestrator.swift; sourceTree = "<group>"; };
DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSwitcherView.swift; sourceTree = "<group>"; };
E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsView.swift; sourceTree = "<group>"; };
E7A8DCFA77626F9999B432FE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
EB28950392C0198E69F3564B /* AppSettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsController.swift; sourceTree = "<group>"; };
EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyManager.swift; sourceTree = "<group>"; };
EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenRegistryTests.swift; sourceTree = "<group>"; };
EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindow.swift; sourceTree = "<group>"; };
F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCommandArrowBehavior.swift; sourceTree = "<group>"; };
F3B957BD1F6120D2592613ED /* WorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStore.swift; sourceTree = "<group>"; };
F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacesSettingsView.swift; sourceTree = "<group>"; };
F9220E02B6D470DD05CA540C /* WindowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCoordinator.swift; sourceTree = "<group>"; };
FE99DBAB656EA80A117D2EE1 /* AnimationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationSettingsView.swift; sourceTree = "<group>"; };
FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestratorTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
F3D057FF4247F13A1BBAE547 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5379DF2FACE924BDDB584377 /* SwiftTerm in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
00BC30DD7FA5C3C26404733B /* Models */ = {
isa = PBXGroup;
children = (
74463E4EAB78F56345360CD5 /* AppSettings.swift */,
EB28950392C0198E69F3564B /* AppSettingsController.swift */,
9E6C98C406899F4B242075AF /* AppSettingsStore.swift */,
8BB1C403BC2157756F572ACF /* HotkeyBinding.swift */,
DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */,
297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */,
4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */,
7181BB1F3926B457445105E5 /* ScreenContext.swift */,
AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */,
567E85A2ED628460CEC760DB /* TerminalManager.swift */,
49E1791BB45E1505500ACC67 /* TerminalSession.swift */,
CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */,
3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */,
0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */,
F3B957BD1F6120D2592613ED /* WorkspaceStore.swift */,
7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */,
);
path = Models;
sourceTree = "<group>";
};
618799FE544A4373B457DCDA /* Extensions */ = {
isa = PBXGroup;
children = (
5DDC7451F0E7ACFE0BEC5473 /* NSScreen+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
7043235A31A4023478DA1302 = {
isa = PBXGroup;
children = (
84740FA9CF6A18B35EC82623 /* CommandNotch */,
A2F9603AB9C86C4EA62FFA59 /* CommandNotchTests */,
E6C841E864A0CC68B9B05BAC /* CommandNotchUITests */,
B269158E04E8E603B61448F0 /* Products */,
);
sourceTree = "<group>";
};
84740FA9CF6A18B35EC82623 /* CommandNotch */ = {
isa = PBXGroup;
children = (
E7A8DCFA77626F9999B432FE /* AppDelegate.swift */,
5159CB9DBE2BAA0D2E201C39 /* CommandNotchApp.swift */,
A64A11F27E65B342B991629A /* ContentView.swift */,
D3C008AD1EFEF08E3417396F /* Components */,
618799FE544A4373B457DCDA /* Extensions */,
D87DBBE2E8779343A36F3490 /* Managers */,
00BC30DD7FA5C3C26404733B /* Models */,
C2F0251EB52960C9F437154D /* Resources */,
DEE792769214DF028395EA86 /* Views */,
);
path = CommandNotch;
sourceTree = "<group>";
};
A2F9603AB9C86C4EA62FFA59 /* CommandNotchTests */ = {
isa = PBXGroup;
children = (
3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */,
39CA6F2C2CA32D0AA58F6C43 /* AppSettingsStoreTests.swift */,
FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */,
A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */,
EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */,
C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */,
D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */,
591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */,
4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */,
);
path = CommandNotchTests;
sourceTree = "<group>";
};
B269158E04E8E603B61448F0 /* Products */ = {
isa = PBXGroup;
children = (
B5AA68DC0DD3FE07B56EB6EC /* CommandNotch.app */,
35CBC14E11EBD8486457CE91 /* CommandNotchTests.xctest */,
496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
C2F0251EB52960C9F437154D /* Resources */ = {
isa = PBXGroup;
children = (
6ADD641A088A5005DD0EB4A6 /* Assets.xcassets */,
8796768C84519077354A95C7 /* CommandNotch.entitlements */,
);
path = Resources;
sourceTree = "<group>";
};
D3C008AD1EFEF08E3417396F /* Components */ = {
isa = PBXGroup;
children = (
2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */,
3F57837A7115DEEE11E14B40 /* NotchShape.swift */,
EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */,
9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */,
72A1D3D12BAC593838B3125C /* TabBar.swift */,
F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */,
);
path = Components;
sourceTree = "<group>";
};
D87DBBE2E8779343A36F3490 /* Managers */ = {
isa = PBXGroup;
children = (
EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */,
8210D7783A614FD7190F5DDD /* LaunchAtLoginHelper.swift */,
CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */,
8CB8AF76C0F728897A26D7EF /* ScreenManager.swift */,
728B3125F7F7FDB7313D2DC6 /* SettingsWindowController.swift */,
F9220E02B6D470DD05CA540C /* WindowCoordinator.swift */,
);
path = Managers;
sourceTree = "<group>";
};
DEE792769214DF028395EA86 /* Views */ = {
isa = PBXGroup;
children = (
48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */,
FE99DBAB656EA80A117D2EE1 /* AnimationSettingsView.swift */,
8B3C8C11834931A9D53BC2B6 /* AppearanceSettingsView.swift */,
27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */,
D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */,
900F0476BE9E3600FBD371BB /* SettingsBindings.swift */,
3125FD3DC55420122CF85D80 /* SettingsView.swift */,
E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */,
F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */,
DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */,
);
path = Views;
sourceTree = "<group>";
};
E6C841E864A0CC68B9B05BAC /* CommandNotchUITests */ = {
isa = PBXGroup;
children = (
726B935606FD961FD7E8C2BE /* CommandNotchUITests.swift */,
);
path = CommandNotchUITests;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
036FDAECD12C0A679DA1F5D6 /* CommandNotchTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 983C618ACECB88BD023F005E /* Build configuration list for PBXNativeTarget "CommandNotchTests" */;
buildPhases = (
4108B7D3B592DEBB95C689C4 /* Sources */,
);
buildRules = (
);
dependencies = (
316871D68B87C00F5A8FEECC /* PBXTargetDependency */,
);
name = CommandNotchTests;
packageProductDependencies = (
);
productName = CommandNotchTests;
productReference = 35CBC14E11EBD8486457CE91 /* CommandNotchTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
1C8D00CBB29219BD347E9CC4 /* CommandNotchUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = CEAE976FCC3F5BD54E57E585 /* Build configuration list for PBXNativeTarget "CommandNotchUITests" */;
buildPhases = (
1215938A5211847A086CC444 /* Sources */,
);
buildRules = (
);
dependencies = (
F9E4A521E345300B08E257EA /* PBXTargetDependency */,
);
name = CommandNotchUITests;
packageProductDependencies = (
);
productName = CommandNotchUITests;
productReference = 496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
D5585E5732CD067DF6EF0C69 /* CommandNotch */ = {
isa = PBXNativeTarget;
buildConfigurationList = E599EC932C075AF0FD763A0E /* Build configuration list for PBXNativeTarget "CommandNotch" */;
buildPhases = (
D5B7874B63551D908A4B76C8 /* Sources */,
3BD8FCDCDA6E37ED22A35CA5 /* Resources */,
F3D057FF4247F13A1BBAE547 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = CommandNotch;
packageProductDependencies = (
C921E6435A64AA07A0FEA4D5 /* SwiftTerm */,
);
productName = CommandNotch;
productReference = B5AA68DC0DD3FE07B56EB6EC /* CommandNotch.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
E99C2EDB39F7B64C1540BCA8 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1600;
TargetAttributes = {
1C8D00CBB29219BD347E9CC4 = {
DevelopmentTeam = G698BP272N;
TestTargetID = D5585E5732CD067DF6EF0C69;
};
};
};
buildConfigurationList = C47A3896770C98F2A3E62B7A /* Build configuration list for PBXProject "CommandNotch" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 7043235A31A4023478DA1302;
minimizedProjectReferenceProxies = 1;
packageReferences = (
28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */,
);
preferredProjectObjectVersion = 77;
projectDirPath = "";
projectRoot = "";
targets = (
D5585E5732CD067DF6EF0C69 /* CommandNotch */,
036FDAECD12C0A679DA1F5D6 /* CommandNotchTests */,
1C8D00CBB29219BD347E9CC4 /* CommandNotchUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
3BD8FCDCDA6E37ED22A35CA5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7AB14019B5CC6ED84B96FA47 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1215938A5211847A086CC444 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
278010607B0D552DCC8996C5 /* CommandNotchUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
4108B7D3B592DEBB95C689C4 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F8F8DE2F26608259D72635B7 /* AppSettingsControllerTests.swift in Sources */,
7D5F3C3B5E173B6F06901812 /* AppSettingsStoreTests.swift in Sources */,
2089566A2BBAA65EA82119B3 /* NotchOrchestratorTests.swift in Sources */,
D2468B19D6F0A2C1DFDFE2B7 /* ScreenContextTests.swift in Sources */,
8A1B2A4CD61B0D9A4BAB075B /* ScreenRegistryTests.swift in Sources */,
CFBBE994BA6BAD4658AAB9CB /* TerminalCommandArrowBehaviorTests.swift in Sources */,
0F133E8A88D2E313D90C32AD /* WindowFrameCalculatorTests.swift in Sources */,
154F363D434A26105C5999B5 /* WorkspaceRegistryTests.swift in Sources */,
2375B9DA559A0777FE558A8B /* WorkspaceStoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D5B7874B63551D908A4B76C8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B605A9311557251A85183383 /* AboutSettingsView.swift in Sources */,
88D87155D79C493D8956AA3B /* AnimationSettingsView.swift in Sources */,
6DD4DAE72E5C3858B230D94C /* AppDelegate.swift in Sources */,
7A7D3455D50D041CF698E786 /* AppSettings.swift in Sources */,
B406183CC7B5DCE76989A066 /* AppSettingsController.swift in Sources */,
4D335F67B71F7DD977B6AEF9 /* AppSettingsStore.swift in Sources */,
0DE8E7F29FBAA7E2B613E221 /* AppearanceSettingsView.swift in Sources */,
D088BC850F37E717844761C6 /* CommandNotchApp.swift in Sources */,
C84FA3100884649FE92BF5DD /* ContentView.swift in Sources */,
34AF69BF7AF8DC78ADE3774A /* GeneralSettingsView.swift in Sources */,
9F535B9516A4AE9FB536B1BD /* HotkeyBinding.swift in Sources */,
187F4B521BFC3BD29ADA79E3 /* HotkeyManager.swift in Sources */,
E792411FA82E79E810F4B4C3 /* HotkeyRecorderView.swift in Sources */,
6A18F6635B509FF58669F505 /* HotkeySettingsView.swift in Sources */,
12F68EDA880030DAB644FF5F /* LaunchAtLoginHelper.swift in Sources */,
A74E14B8AD19C53820853D8E /* NSScreen+Extensions.swift in Sources */,
EE72479BA5A25FF31BACCC50 /* NotchOrchestrator.swift in Sources */,
3B69CB3CDEC2E5F2DCE600F9 /* NotchSettings.swift in Sources */,
088FE22B3308AAB25027E086 /* NotchShape.swift in Sources */,
23E2DDCF36D0DAB2EA72C39C /* NotchState.swift in Sources */,
4E0AD14B7427532271E485AA /* NotchWindow.swift in Sources */,
D4CEB4B895F75D91FA892A06 /* PopoutWindowController.swift in Sources */,
787477BB6D2F8AC723EEDA7D /* ScreenContext.swift in Sources */,
F8ED522FDC96B1F9AD8933F7 /* ScreenManager.swift in Sources */,
E63FD5862C0EC54E284F6A0F /* ScreenRegistry.swift in Sources */,
771088361F981A9AAE976F3C /* SettingsBindings.swift in Sources */,
DAD3AB4A0DAADA32C02D959E /* SettingsView.swift in Sources */,
5A3FDFCF30A1AAFE070290E9 /* SettingsWindowController.swift in Sources */,
5F3534F66A5DBE4E081AFFA6 /* SwiftTermView.swift in Sources */,
88113BA9B217DA579C36BEBE /* TabBar.swift in Sources */,
DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */,
6F249EDFA2D654457DF385F1 /* TerminalManager.swift in Sources */,
7A69B5AAC686174BCA54D0F0 /* TerminalSession.swift in Sources */,
65C7DB7296C6C6A77598A1F4 /* TerminalSettingsView.swift in Sources */,
1AB4A0F1BE668D3130EFBA93 /* TerminalTheme.swift in Sources */,
507A67E770DEFAF5BC321FCF /* WindowCoordinator.swift in Sources */,
86BB4A41E8EC44A5F45CE995 /* WorkspaceController.swift in Sources */,
4CFA0C79095ACE0A327A2469 /* WorkspaceRegistry.swift in Sources */,
AE8215538D4B026A00BAD241 /* WorkspaceStore.swift in Sources */,
2DF22798D3A7514E2A9183FC /* WorkspaceSummary.swift in Sources */,
6624A60CB121A22A03365071 /* WorkspaceSwitcherView.swift in Sources */,
26AE379040149CBE05B314BB /* WorkspacesSettingsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
316871D68B87C00F5A8FEECC /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D5585E5732CD067DF6EF0C69 /* CommandNotch */;
targetProxy = 13E720E97D079D298D124BBE /* PBXContainerItemProxy */;
};
F9E4A521E345300B08E257EA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D5585E5732CD067DF6EF0C69 /* CommandNotch */;
targetProxy = 9D6387038E18398CA764147F /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
15F1D354AC7D5666A8317E25 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = CommandNotch/Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app;
PRODUCT_NAME = CommandNotch;
SDKROOT = macosx;
};
name = Debug;
};
4C713B8FE8B6293021AB974F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COMBINE_HIDPI_IMAGES = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchTests;
PRODUCT_NAME = CommandNotchTests;
SDKROOT = macosx;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch";
};
name = Release;
};
7D032F3A06E313F1F92D39EC /* 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;
};
860E4EAD454534A04683E7DD /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = CommandNotch/Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app;
PRODUCT_NAME = CommandNotch;
SDKROOT = macosx;
};
name = Release;
};
87EEF9DE40EE78121DC1E68B /* 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;
};
C9CA7CD89BF1C9A9BC98C4CF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = G698BP272N;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchUITests;
PRODUCT_NAME = CommandNotchUITests;
SDKROOT = macosx;
TEST_TARGET_NAME = CommandNotch;
};
name = Debug;
};
E52824EDC7F4752F43B6301A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COMBINE_HIDPI_IMAGES = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchTests;
PRODUCT_NAME = CommandNotchTests;
SDKROOT = macosx;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch";
};
name = Debug;
};
F697F3FF95C1EB110FC25A5C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = G698BP272N;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchUITests;
PRODUCT_NAME = CommandNotchUITests;
SDKROOT = macosx;
TEST_TARGET_NAME = CommandNotch;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
983C618ACECB88BD023F005E /* Build configuration list for PBXNativeTarget "CommandNotchTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E52824EDC7F4752F43B6301A /* Debug */,
4C713B8FE8B6293021AB974F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
C47A3896770C98F2A3E62B7A /* Build configuration list for PBXProject "CommandNotch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7D032F3A06E313F1F92D39EC /* Debug */,
87EEF9DE40EE78121DC1E68B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
CEAE976FCC3F5BD54E57E585 /* Build configuration list for PBXNativeTarget "CommandNotchUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C9CA7CD89BF1C9A9BC98C4CF /* Debug */,
F697F3FF95C1EB110FC25A5C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
E599EC932C075AF0FD763A0E /* Build configuration list for PBXNativeTarget "CommandNotch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
15F1D354AC7D5666A8317E25 /* Debug */,
860E4EAD454534A04683E7DD /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.2.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
C921E6435A64AA07A0FEA4D5 /* SwiftTerm */ = {
isa = XCSwiftPackageProductDependency;
package = 28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */;
productName = SwiftTerm;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = E99C2EDB39F7B64C1540BCA8 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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
}

View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "036FDAECD12C0A679DA1F5D6"
BuildableName = "CommandNotchTests.xctest"
BlueprintName = "CommandNotchTests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8D00CBB29219BD347E9CC4"
BuildableName = "CommandNotchUITests.xctest"
BlueprintName = "CommandNotchUITests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "036FDAECD12C0A679DA1F5D6"
BuildableName = "CommandNotchTests.xctest"
BlueprintName = "CommandNotchTests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8D00CBB29219BD347E9CC4"
BuildableName = "CommandNotchUITests.xctest"
BlueprintName = "CommandNotchUITests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,37 @@
<?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>CommandNotch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>CommandNotchTests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>CommandNotchUITests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>Release-CommandNotch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>1485207FA11756EC2DF4F08B</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,117 @@
import AppKit
import Combine
/// Application delegate that bootstraps the notch overlay system.
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
private enum UITestLaunchArgument {
static let regularActivation = "--uitest-regular-activation"
static let showSettings = "--uitest-show-settings"
static let openNotch = "--uitest-open-notch"
}
private var cancellables = Set<AnyCancellable>()
private let settingsController = AppSettingsController.shared
func applicationDidFinishLaunching(_ notification: Notification) {
NotchSettings.registerDefaults()
if isRunningUITests {
NSApp.setActivationPolicy(.regular)
} else {
NSApp.setActivationPolicy(.accessory)
}
// Sync the launch-at-login toggle with the actual system state
// in case the user toggled it from System Settings.
settingsController.update {
$0.display.launchAtLogin = LaunchAtLoginHelper.isEnabled
}
ScreenManager.shared.start()
observeDisplayPreference()
observeSizePreferences()
observeFontSizeChanges()
observeTerminalThemeChanges()
applyUITestLaunchBehaviorIfNeeded()
}
func applicationWillTerminate(_ notification: Notification) {
ScreenManager.shared.stop()
}
// MARK: - Preference observers
/// Only rebuild windows when the display-count preference changes.
private func observeDisplayPreference() {
settingsController.$settings
.map(\.display.showOnAllDisplays)
.removeDuplicates()
.dropFirst()
.sink { _ in
ScreenManager.shared.rebuildWindows()
}
.store(in: &cancellables)
}
/// Reposition (not rebuild) when any sizing preference changes.
private func observeSizePreferences() {
settingsController.$settings
.map(\.display.layoutSignature)
.removeDuplicates()
.dropFirst()
.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() {
settingsController.$settings
.map(\.terminal.fontSize)
.removeDuplicates()
.sink { newSize in
guard newSize > 0 else { return }
WorkspaceRegistry.shared.updateAllWorkspacesFontSizes(CGFloat(newSize))
}
.store(in: &cancellables)
}
/// Live-update terminal colors across all sessions.
private func observeTerminalThemeChanges() {
settingsController.$settings
.map(\.terminal.themeRawValue)
.removeDuplicates()
.sink { newTheme in
WorkspaceRegistry.shared.updateAllWorkspacesThemes(TerminalTheme.resolve(newTheme))
}
.store(in: &cancellables)
}
private var launchArguments: [String] {
ProcessInfo.processInfo.arguments
}
private var isRunningUITests: Bool {
launchArguments.contains(UITestLaunchArgument.regularActivation)
|| launchArguments.contains(UITestLaunchArgument.showSettings)
|| launchArguments.contains(UITestLaunchArgument.openNotch)
}
private func applyUITestLaunchBehaviorIfNeeded() {
guard isRunningUITests else { return }
DispatchQueue.main.async { @MainActor in
if self.launchArguments.contains(UITestLaunchArgument.showSettings) {
SettingsWindowController.shared.showSettings()
}
if self.launchArguments.contains(UITestLaunchArgument.openNotch),
let screenID = ScreenRegistry.shared.activeScreenID() {
ScreenManager.shared.openNotch(screenID: screenID)
}
}
}
}

View File

@@ -0,0 +1,43 @@
import SwiftUI
/// Main entry point for the CommandNotch 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 CommandNotchApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var settingsController = AppSettingsController.shared
var body: some Scene {
MenuBarExtra(
"CommandNotch",
systemImage: "terminal",
isInserted: Binding(
get: { settingsController.settings.display.showMenuBarIcon },
set: { newValue in
settingsController.update { $0.display.showMenuBarIcon = newValue }
}
)
) {
Button("Toggle Notch") {
ScreenManager.shared.toggleNotchOnActiveScreen()
}
.keyboardShortcut(.return, modifiers: .command)
Divider()
Button("Settings...") {
SettingsWindowController.shared.showSettings()
}
.keyboardShortcut(",", modifiers: .command)
Divider()
Button("Quit CommandNotch") {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("Q", modifiers: .command)
}
}
}

View File

@@ -0,0 +1,192 @@
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)
)
}
}
}
struct OptionalHotkeyRecorderView: 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)
OptionalHotkeyRecorderField(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)
)
Button("Clear") {
binding = nil
}
.disabled(binding == nil)
}
}
}
/// 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.update(currentLabel: binding.displayString, isRecording: isRecording)
}
}
struct OptionalHotkeyRecorderField: 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.update(currentLabel: binding?.displayString ?? "Not set", isRecording: isRecording)
}
}
/// 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)?
private let label: NSTextField = {
let field = NSTextField(labelWithString: "")
field.alignment = .center
field.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
field.translatesAutoresizingMaskIntoConstraints = false
field.backgroundColor = .clear
field.isBezeled = false
field.lineBreakMode = .byTruncatingTail
return field
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupLabel()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupLabel()
}
override var acceptsFirstResponder: Bool { true }
override func layout() {
super.layout()
updateLabelAppearance()
}
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)
}
private func setupLabel() {
addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6),
label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6),
label.centerYAnchor.constraint(equalTo: centerYAnchor)
])
updateLabelAppearance()
}
func update(currentLabel: String, isRecording: Bool) {
self.currentLabel = currentLabel
showRecording = isRecording
updateLabelAppearance()
}
private func updateLabelAppearance() {
label.stringValue = showRecording ? "Press keys..." : currentLabel
label.textColor = showRecording ? .controlAccentColor : .labelColor
}
}

View 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
/// top-edge cut-ins of the closed notch, and a clean rounded-bottom
/// rectangle when open (topCornerRadius approaches 0).
struct NotchShape: Shape {
/// Radius applied to the top-left and top-right transitions where the notch
/// curves away from the screen edge. When close to 0, the top corners become
/// sharp and the shape is a rectangle with rounded bottom corners.
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 {
// Leave the screen edge horizontally, then turn into the side wall.
path.addQuadCurve(
to: CGPoint(x: minX + topR, y: minY + topR),
control: CGPoint(x: minX + topR, y: minY)
)
} 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 the top-right transition
path.addLine(to: CGPoint(x: maxX - topR, y: minY + topR))
if topR > 0.5 {
// Mirror the top-left transition.
path.addQuadCurve(
to: CGPoint(x: maxX, y: minY),
control: CGPoint(x: maxX - topR, y: minY)
)
} 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 top-edge cut-ins, just rounded bottom corners.
/// topCornerRadius is near-zero so the top becomes effectively flat and the panel
/// extends flush to the top edge of the screen.
static var opened: NotchShape {
NotchShape(topCornerRadius: 0, bottomCornerRadius: 24)
}
}

View 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?()
}
}
}
}

View 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),
])
}
}

View 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 workspace: WorkspaceController
var body: some View {
HStack(spacing: 0) {
Button {
workspace.newTab()
} label: {
Image(systemName: "plus")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
}
.accessibilityLabel("New Tab")
.accessibilityIdentifier("notch.new-tab")
.buttonStyle(.plain)
.padding(.horizontal, 8)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(Array(workspace.tabs.enumerated()), id: \.element.id) { index, tab in
tabButton(for: tab, at: index)
}
}
.padding(.horizontal, 4)
}
}
.frame(height: 28)
.background(.black)
}
@ViewBuilder
private func tabButton(for tab: TerminalSession, at index: Int) -> some View {
let isActive = index == workspace.activeTabIndex
HStack(spacing: 4) {
Text(abbreviateTitle(tab.title))
.font(.system(size: 11))
.lineLimit(1)
.foregroundStyle(isActive ? .white : .white.opacity(0.5))
if isActive && workspace.tabs.count > 1 {
Button {
workspace.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 {
workspace.switchToTab(at: index)
}
}
private func abbreviateTitle(_ title: String) -> String {
title.count <= 24 ? title : String(title.prefix(22)) + ""
}
}

View File

@@ -0,0 +1,31 @@
import AppKit
import Carbon.HIToolbox
import SwiftTerm
enum TerminalCommandArrowBehavior {
private static let relevantModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
private static let lineKill: [UInt8] = [0x15]
private static let clearScreen: [UInt8] = [0x0c]
static func sequence(
for modifierFlags: NSEvent.ModifierFlags,
keyCode: UInt16,
applicationCursor: Bool
) -> [UInt8]? {
let flags = modifierFlags.intersection(relevantModifiers)
guard flags == [.command] else { return nil }
switch Int(keyCode) {
case kVK_LeftArrow:
return applicationCursor ? EscapeSequences.moveHomeApp : EscapeSequences.moveHomeNormal
case kVK_RightArrow:
return applicationCursor ? EscapeSequences.moveEndApp : EscapeSequences.moveEndNormal
case kVK_Delete:
return lineKill
case kVK_ANSI_L:
return clearScreen
default:
return nil
}
}
}

View File

@@ -0,0 +1,277 @@
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 screen: ScreenContext
let orchestrator: NotchOrchestrator
@ObservedObject private var settingsController = AppSettingsController.shared
@ObservedObject private var screenRegistry = ScreenRegistry.shared
@State private var resizeStartSize: CGSize?
@State private var resizeStartMouseLocation: CGPoint?
private var hoverAnimation: Animation {
.interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping)
}
private var currentShape: NotchShape {
screen.notchState == .open
? (cornerRadiusScaling ? .opened : NotchShape(topCornerRadius: 0, bottomCornerRadius: 14))
: .closed
}
private var enableShadow: Bool {
settingsController.settings.appearance.enableShadow
}
private var shadowRadius: Double {
settingsController.settings.appearance.shadowRadius
}
private var shadowOpacity: Double {
settingsController.settings.appearance.shadowOpacity
}
private var cornerRadiusScaling: Bool {
settingsController.settings.appearance.cornerRadiusScaling
}
private var notchOpacity: Double {
settingsController.settings.appearance.notchOpacity
}
private var blurRadius: Double {
settingsController.settings.appearance.blurRadius
}
private var hoverSpringResponse: Double {
settingsController.settings.animation.hoverSpringResponse
}
private var hoverSpringDamping: Double {
settingsController.settings.animation.hoverSpringDamping
}
// MARK: - Body
var body: some View {
notchBody
.accessibilityIdentifier("notch.container")
.frame(
width: screen.notchSize.width,
height: screen.notchState == .open ? screen.notchSize.height : screen.closedNotchSize.height,
alignment: .top
)
.background(.black)
.clipShape(currentShape)
.overlay(alignment: .top) {
Rectangle().fill(.black).frame(height: 1)
}
.overlay(alignment: .bottomTrailing) {
if screen.notchState == .open {
resizeHandle
}
}
.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(screen.notchState == .open ? screen.openAnimation : screen.closeAnimation, value: screen.notchState)
.animation(sizeAnimation, value: screen.notchSize.width)
.animation(sizeAnimation, value: screen.notchSize.height)
.onHover { handleHover($0) }
.onDisappear {
resizeStartSize = nil
resizeStartMouseLocation = nil
screen.endInteractiveResize()
orchestrator.handleHoverChange(false, for: screen.id)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.edgesIgnoringSafeArea(.all)
}
// MARK: - Content
@ViewBuilder
private var notchBody: some View {
WorkspaceScopedView(screen: screen, screenRegistry: screenRegistry) { workspace in
if screen.notchState == .open {
openContent(workspace: workspace)
.transition(.opacity)
} else {
closedContent(workspace: workspace)
}
}
}
private func closedContent(workspace: WorkspaceController) -> some View {
HStack {
Spacer()
Text(abbreviate(workspace.activeTitle))
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 8)
.background(.black)
}
private var resizeHandle: some View {
ResizeHandleShape()
.stroke(.white.opacity(0.32), style: StrokeStyle(lineWidth: 1.2, lineCap: .round))
.frame(width: 16, height: 16)
.padding(.trailing, 8)
.padding(.bottom, 8)
.contentShape(Rectangle().inset(by: -8))
.gesture(resizeGesture)
}
private var resizeGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if resizeStartSize == nil {
resizeStartSize = screen.notchSize
resizeStartMouseLocation = NSEvent.mouseLocation
screen.beginInteractiveResize()
}
guard let startSize = resizeStartSize,
let startMouseLocation = resizeStartMouseLocation else { return }
let currentMouseLocation = NSEvent.mouseLocation
screen.resizeOpenNotch(
to: CGSize(
width: startSize.width + ((currentMouseLocation.x - startMouseLocation.x) * 2),
height: startSize.height + (startMouseLocation.y - currentMouseLocation.y)
)
)
}
.onEnded { _ in
resizeStartSize = nil
resizeStartMouseLocation = nil
screen.endInteractiveResize()
}
}
private var sizeAnimation: Animation? {
guard !screen.isUserResizing, !screen.isPresetResizing else { return nil }
return screen.notchState == .open ? screen.openAnimation : screen.closeAnimation
}
/// Open layout: VStack with toolbar row on top, terminal in the middle,
/// tab bar at the bottom. Every section has a black background.
private func openContent(workspace: WorkspaceController) -> some View {
VStack(spacing: 0) {
// Toolbar row right-aligned, solid black
HStack {
WorkspaceSwitcherView(screen: screen, orchestrator: orchestrator)
Spacer()
toolbarButton(icon: "arrow.up.forward.square", help: "Detach tab") {
if let session = workspace.detachActiveTab() {
PopoutWindowController.shared.popout(session: session)
}
}
toolbarButton(icon: "gearshape.fill", help: "Settings") {
SettingsWindowController.shared.showSettings()
}
}
.padding(.top, 6)
.padding(.leading, 10)
.padding(.trailing, 10)
.padding(.bottom, 2)
.background(.black)
// Terminal fills remaining space
if let session = workspace.activeTab {
SwiftTermView(session: session)
.id(session.id)
.padding(.leading, 10)
.padding(.trailing, 10)
}
// Tab bar
TabBar(workspace: workspace)
}
.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)
.accessibilityLabel(help)
.accessibilityIdentifier("notch.toolbar.\(icon)")
.help(help)
}
// MARK: - Hover
private func handleHover(_ hovering: Bool) {
withAnimation(hoverAnimation) {
orchestrator.handleHoverChange(hovering, for: screen.id)
}
}
private func abbreviate(_ title: String) -> String {
title.count <= 30 ? title : String(title.prefix(28)) + ""
}
}
private struct ResizeHandleShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.maxX - 10, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 10))
path.move(to: CGPoint(x: rect.maxX - 6, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 6))
path.move(to: CGPoint(x: rect.maxX - 2, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 2))
return path
}
}
private struct WorkspaceScopedView<Content: View>: View {
@ObservedObject var screen: ScreenContext
@ObservedObject var screenRegistry: ScreenRegistry
let content: (WorkspaceController) -> Content
init(
screen: ScreenContext,
screenRegistry: ScreenRegistry,
@ViewBuilder content: @escaping (WorkspaceController) -> Content
) {
self.screen = screen
self.screenRegistry = screenRegistry
self.content = content
}
var body: some View {
WorkspaceObservedView(workspace: screenRegistry.workspaceController(for: screen.id), content: content)
.id(screen.workspaceID)
}
}
private struct WorkspaceObservedView<Content: View>: View {
@ObservedObject var workspace: WorkspaceController
let content: (WorkspaceController) -> Content
var body: some View {
content(workspace)
}
}

View File

@@ -0,0 +1,83 @@
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(using settings: AppSettings.DisplaySettings) -> CGSize {
let height = closedNotchHeight(using: settings)
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(using settings: AppSettings.DisplaySettings) -> CGFloat {
if hasNotch {
let mode = NotchHeightMode(rawValue: settings.notchHeightMode)
?? .matchRealNotchSize
switch mode {
case .matchRealNotchSize:
return safeAreaInsets.top
case .matchMenuBar:
return menuBarHeight()
case .custom:
return settings.notchHeight
}
} else {
let mode = NonNotchHeightMode(rawValue: settings.nonNotchHeightMode)
?? .matchMenuBar
switch mode {
case .matchMenuBar:
return menuBarHeight()
case .custom:
return settings.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
}
}

View File

@@ -0,0 +1,280 @@
import AppKit
import Carbon.HIToolbox
import Combine
/// Manages global and local hotkeys.
///
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
/// system-wide without Accessibility permission. Notch-scoped hotkeys
/// use a local `NSEvent` monitor (only fires when our app is active).
@MainActor
class HotkeyManager {
static let shared = HotkeyManager()
// MARK: - Callbacks
var onToggle: (() -> Void)?
var onNewTab: (() -> Void)?
var onCloseTab: (() -> Void)?
var onNextTab: (() -> Void)?
var onPreviousTab: (() -> Void)?
var onNextWorkspace: (() -> Void)?
var onPreviousWorkspace: (() -> Void)?
var onDetachTab: (() -> Void)?
var onApplySizePreset: ((TerminalSizePreset) -> Void)?
var onSwitchToTab: ((Int) -> Void)?
var onSwitchToWorkspace: ((WorkspaceID) -> Void)?
/// Notch-scoped 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 let settingsProvider: TerminalSessionConfigurationProviding
private let workspaceRegistry: WorkspaceRegistry
private var settingsCancellable: AnyCancellable?
init(
settingsProvider: TerminalSessionConfigurationProviding? = nil,
workspaceRegistry: WorkspaceRegistry? = nil
) {
self.settingsProvider = settingsProvider ?? AppSettingsController.shared
self.workspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared
}
// MARK: - Resolved bindings from typed runtime settings
private var toggleBinding: HotkeyBinding {
settingsProvider.hotkeySettings.toggle
}
private var newTabBinding: HotkeyBinding {
settingsProvider.hotkeySettings.newTab
}
private var closeTabBinding: HotkeyBinding {
settingsProvider.hotkeySettings.closeTab
}
private var nextTabBinding: HotkeyBinding {
settingsProvider.hotkeySettings.nextTab
}
private var prevTabBinding: HotkeyBinding {
settingsProvider.hotkeySettings.previousTab
}
private var nextWorkspaceBinding: HotkeyBinding {
settingsProvider.hotkeySettings.nextWorkspace
}
private var previousWorkspaceBinding: HotkeyBinding {
settingsProvider.hotkeySettings.previousWorkspace
}
private var detachBinding: HotkeyBinding {
settingsProvider.hotkeySettings.detachTab
}
private var sizePresets: [TerminalSizePreset] {
settingsProvider.terminalSizePresets
}
// MARK: - Start / Stop
func start() {
installCarbonHandler()
registerToggleHotkey()
installLocalMonitor()
observeToggleHotkeyChanges()
}
func stop() {
unregisterToggleHotkey()
removeCarbonHandler()
removeLocalMonitor()
settingsCancellable = 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)
let 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 typed settings change.
private func observeToggleHotkeyChanges() {
guard let settingsProvider = settingsProvider as? AppSettingsController else { return }
settingsCancellable = settingsProvider.$settings
.map(\.hotkeys.toggle)
.removeDuplicates()
.dropFirst()
.sink { [weak self] _ in
self?.registerToggleHotkey()
}
}
// MARK: - Local monitor (notch-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 notch-scoped hotkeys. Returns true if the event was consumed.
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
// Local shortcuts only fire 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 nextWorkspaceBinding.matches(event) {
onNextWorkspace?()
return true
}
if previousWorkspaceBinding.matches(event) {
onPreviousWorkspace?()
return true
}
if detachBinding.matches(event) {
onDetachTab?()
return true
}
for summary in workspaceRegistry.workspaceSummaries {
guard let binding = summary.hotkey else { continue }
if binding.matches(event) {
onSwitchToWorkspace?(summary.id)
return true
}
}
for preset in sizePresets {
guard let binding = preset.hotkey else { continue }
if binding.matches(event) {
onApplySizePreset?(preset)
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
}
}

View File

@@ -0,0 +1,24 @@
import ServiceManagement
/// Registers / unregisters the app as a login item using the
/// modern SMAppService API (macOS 13+).
enum LaunchAtLoginHelper {
static func setEnabled(_ enabled: Bool) {
let service = SMAppService.mainApp
do {
if enabled {
try service.register()
} else {
try service.unregister()
}
} catch {
print("[LaunchAtLogin] Failed to \(enabled ? "register" : "unregister"): \(error)")
}
}
/// Reads the current registration state from the system.
static var isEnabled: Bool {
SMAppService.mainApp.status == .enabled
}
}

View File

@@ -0,0 +1,87 @@
import AppKit
import SwiftUI
import Combine
/// 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 var titleObservers: [UUID: AnyCancellable] = [:]
private override init() {
super.init()
}
/// Creates a new standalone window for the given terminal session.
func popout(session: TerminalSession) {
let windowID = session.id
if let existingWindow = windows[windowID] {
existingWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
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
let hostingView = NSHostingView(
rootView: SwiftTermView(session: session)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.preferredColorScheme(.dark)
)
hostingView.frame = NSRect(origin: .zero, size: win.contentRect(forFrameRect: win.frame).size)
win.contentView = hostingView
win.center()
win.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
windows[windowID] = win
sessions[windowID] = session
// Update window title when the terminal title changes
titleObservers[windowID] = session.$title
.receive(on: RunLoop.main)
.sink { [weak win] title in win?.title = title }
}
// MARK: - NSWindowDelegate
func windowDidBecomeKey(_ notification: Notification) {
guard let window = notification.object as? NSWindow,
let entry = windows.first(where: { $0.value === window }),
let terminalView = sessions[entry.key]?.terminalView,
terminalView.window === window else { return }
window.makeFirstResponder(terminalView)
}
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)
titleObservers.removeValue(forKey: entry.key)
}
}
}

View File

@@ -0,0 +1,297 @@
import AppKit
import Combine
import SwiftUI
/// Coordinates screen/workspace state with notch lifecycle and
/// delegates raw window work to `WindowCoordinator`.
@MainActor
final class ScreenManager: ObservableObject {
static let shared = ScreenManager()
private let screenRegistry = ScreenRegistry.shared
private let workspaceRegistry = WorkspaceRegistry.shared
private let windowCoordinator = WindowCoordinator()
private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self)
private var cancellables = Set<AnyCancellable>()
private init() {}
private var showOnAllDisplays: Bool {
AppSettingsController.shared.settings.display.showOnAllDisplays
}
// MARK: - Lifecycle
func start() {
screenRegistry.refreshConnectedScreens()
observeScreenChanges()
rebuildWindows()
setupHotkeys()
}
func stop() {
cleanupAllWindows()
cancellables.removeAll()
HotkeyManager.shared.stop()
}
// MARK: - Hotkey wiring
private func setupHotkeys() {
let hotkeyManager = HotkeyManager.shared
hotkeyManager.onToggle = { [weak self] in
MainActor.assumeIsolated { self?.orchestrator.toggleOnActiveScreen() }
}
hotkeyManager.onNewTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().newTab() }
}
hotkeyManager.onCloseTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().closeActiveTab() }
}
hotkeyManager.onNextTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().nextTab() }
}
hotkeyManager.onPreviousTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().previousTab() }
}
hotkeyManager.onNextWorkspace = { [weak self] in
MainActor.assumeIsolated { self?.switchWorkspace(offset: 1) }
}
hotkeyManager.onPreviousWorkspace = { [weak self] in
MainActor.assumeIsolated { self?.switchWorkspace(offset: -1) }
}
hotkeyManager.onDetachTab = { [weak self] in
MainActor.assumeIsolated { self?.detachActiveTab() }
}
hotkeyManager.onApplySizePreset = { [weak self] preset in
MainActor.assumeIsolated { self?.applySizePreset(preset) }
}
hotkeyManager.onSwitchToTab = { [weak self] index in
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
}
hotkeyManager.onSwitchToWorkspace = { [weak self] workspaceID in
MainActor.assumeIsolated { self?.switchActiveScreen(to: workspaceID) }
}
hotkeyManager.start()
}
// MARK: - Toggle
func toggleNotchOnActiveScreen() {
orchestrator.toggleOnActiveScreen()
}
// MARK: - Open / Close
func openNotch(screenID: ScreenID) {
orchestrator.open(screenID: screenID)
}
func closeNotch(screenID: ScreenID) {
orchestrator.close(screenID: screenID)
}
private func detachActiveTab() {
if let session = activeWorkspace().detachActiveTab() {
DispatchQueue.main.async {
PopoutWindowController.shared.popout(session: session)
}
}
}
private func switchWorkspace(offset: Int) {
guard let screenID = screenRegistry.activeScreenID() else { return }
let currentWorkspaceID = screenRegistry.screenContext(for: screenID)?.workspaceID ?? workspaceRegistry.defaultWorkspaceID
let nextWorkspaceID = offset >= 0
? workspaceRegistry.nextWorkspaceID(after: currentWorkspaceID)
: workspaceRegistry.previousWorkspaceID(before: currentWorkspaceID)
guard let nextWorkspaceID else { return }
switchScreen(screenID, to: nextWorkspaceID)
}
private func switchActiveScreen(to workspaceID: WorkspaceID) {
guard let screenID = screenRegistry.activeScreenID() else { return }
switchScreen(screenID, to: workspaceID)
}
private func switchScreen(_ screenID: ScreenID, to workspaceID: WorkspaceID) {
screenRegistry.assignWorkspace(workspaceID, to: screenID)
guard let context = screenRegistry.screenContext(for: screenID),
context.notchState == .open else {
return
}
orchestrator.open(screenID: screenID)
}
func applySizePreset(_ preset: TerminalSizePreset) {
guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else {
AppSettingsController.shared.update {
$0.display.openWidth = preset.width
$0.display.openHeight = preset.height
}
return
}
let startSize = context.notchSize
let targetSize = context.setStoredOpenSize(preset.size)
windowCoordinator.animatePresetResize(
for: context.id,
context: context,
from: startSize,
to: targetSize,
duration: context.openAnimationDuration
)
}
// MARK: - Window creation
func rebuildWindows() {
cleanupAllWindows()
screenRegistry.refreshConnectedScreens()
for screen in visibleScreens() {
createWindow(for: screen)
}
}
private func createWindow(for screen: NSScreen) {
let screenID = screen.displayUUID
guard let context = screenRegistry.screenContext(for: screenID) else { return }
context.requestOpen = { [weak self] in
self?.orchestrator.open(screenID: screenID)
}
context.requestClose = { [weak self] in
self?.orchestrator.close(screenID: screenID)
}
context.requestWindowResize = { [weak self] in
guard let self,
let context = self.screenRegistry.screenContext(for: screenID) else {
return
}
self.windowCoordinator.updateWindowFrame(
for: screenID,
context: context,
centerHorizontally: true
)
}
context.requestTerminalFocus = { [weak self] in
guard let self else { return }
self.windowCoordinator.focusActiveTerminal(for: screenID) { [weak self] in
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
}
}
let hostingView = NSHostingView(
rootView: ContentView(
screen: context,
orchestrator: orchestrator
)
.preferredColorScheme(.dark)
)
windowCoordinator.createWindow(
on: screen,
context: context,
contentView: hostingView,
onResignKey: { [weak self] in
guard !context.suppressCloseOnFocusLoss else { return }
self?.orchestrator.close(screenID: screenID)
}
)
}
// MARK: - Repositioning
func repositionWindows() {
screenRegistry.refreshConnectedScreens()
for context in screenRegistry.allScreens() {
context.refreshClosedSize()
windowCoordinator.repositionWindow(
for: context.id,
context: context,
centerHorizontally: true
)
}
}
// MARK: - Cleanup
private func cleanupAllWindows() {
orchestrator.cancelAllPendingWork()
windowCoordinator.cleanupAllWindows()
}
// 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() {
screenRegistry.refreshConnectedScreens()
let currentScreenIDs = Set(visibleScreens().map(\.displayUUID))
let knownScreenIDs = windowCoordinator.windowScreenIDs()
if currentScreenIDs != knownScreenIDs {
rebuildWindows()
} else {
repositionWindows()
}
}
private func activeWorkspace() -> WorkspaceController {
guard let screenID = screenRegistry.activeScreenID() else {
return WorkspaceRegistry.shared.defaultWorkspaceController
}
return screenRegistry.workspaceController(for: screenID)
}
private func visibleScreens() -> [NSScreen] {
if showOnAllDisplays {
return NSScreen.screens
}
return [NSScreen.main].compactMap { $0 }
}
}
extension ScreenManager: NotchPresentationHost {
func canPresentNotch(for screenID: ScreenID) -> Bool {
windowCoordinator.hasWindow(for: screenID)
}
func performOpenPresentation(for screenID: ScreenID) {
guard screenRegistry.screenContext(for: screenID) != nil else {
return
}
windowCoordinator.presentOpen(for: screenID) { [weak self] in
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
}
}
func performClosePresentation(for screenID: ScreenID) {
guard screenRegistry.screenContext(for: screenID) != nil else {
return
}
windowCoordinator.presentClose(for: screenID)
}
}

View File

@@ -0,0 +1,59 @@
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 = "CommandNotch Settings"
win.identifier = NSUserInterfaceItemIdentifier("settings.window")
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
}
}

View File

@@ -0,0 +1,291 @@
import AppKit
import QuartzCore
import SwiftUI
struct WindowFrameCalculator {
static let horizontalPadding: CGFloat = 40
static let verticalPadding: CGFloat = 20
static func targetFrame(
screenFrame: CGRect,
currentWindowFrame: CGRect,
notchState: NotchState,
contentSize: CGSize,
centerHorizontally: Bool
) -> CGRect {
let windowWidth = contentSize.width + horizontalPadding
let windowHeight = contentSize.height + verticalPadding
let centeredX = screenFrame.origin.x + ((screenFrame.width - windowWidth) / 2)
let x: CGFloat
if centerHorizontally || notchState == .closed {
x = centeredX
} else {
x = min(
max(currentWindowFrame.minX, screenFrame.minX),
screenFrame.maxX - windowWidth
)
}
return CGRect(
x: x,
y: screenFrame.origin.y + screenFrame.height - windowHeight,
width: windowWidth,
height: windowHeight
)
}
}
@MainActor
final class WindowCoordinator {
private let focusRetryDelay: TimeInterval
private let presetResizeFrameInterval: TimeInterval
private let screenLookup: @MainActor (ScreenID) -> NSScreen?
private let applicationActivator: @MainActor () -> Void
private let hotkeyOpenStateHandler: @MainActor (Bool) -> Void
private(set) var windows: [ScreenID: NotchWindow] = [:]
private var presetResizeTimers: [ScreenID: Timer] = [:]
init(
focusRetryDelay: TimeInterval = 0.01,
presetResizeFrameInterval: TimeInterval = 1.0 / 60.0,
screenLookup: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in
NSScreen.screens.first { $0.displayUUID == screenID }
},
applicationActivator: @escaping @MainActor () -> Void = {
NSApp.activate(ignoringOtherApps: true)
},
hotkeyOpenStateHandler: @escaping @MainActor (Bool) -> Void = { isOpen in
HotkeyManager.shared.isNotchOpen = isOpen
}
) {
self.focusRetryDelay = focusRetryDelay
self.presetResizeFrameInterval = presetResizeFrameInterval
self.screenLookup = screenLookup
self.applicationActivator = applicationActivator
self.hotkeyOpenStateHandler = hotkeyOpenStateHandler
}
func hasWindow(for screenID: ScreenID) -> Bool {
windows[screenID] != nil
}
func windowScreenIDs() -> Set<ScreenID> {
Set(windows.keys)
}
func createWindow(
on screen: NSScreen,
context: ScreenContext,
contentView: NSView,
onResignKey: @escaping () -> Void
) {
let initialFrame = WindowFrameCalculator.targetFrame(
screenFrame: screen.frame,
currentWindowFrame: .zero,
notchState: context.notchState,
contentSize: context.openNotchSize,
centerHorizontally: true
)
let window = NotchWindow(
contentRect: initialFrame,
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
backing: .buffered,
defer: false
)
window.onResignKey = onResignKey
let containerView = NSView(frame: NSRect(origin: .zero, size: initialFrame.size))
containerView.autoresizesSubviews = true
containerView.wantsLayer = true
containerView.layer?.backgroundColor = NSColor.clear.cgColor
contentView.frame = containerView.bounds
contentView.autoresizingMask = [.width, .height]
containerView.addSubview(contentView)
window.contentView = containerView
windows[context.id] = window
updateWindowFrame(for: context.id, context: context, centerHorizontally: true)
window.orderFrontRegardless()
}
func repositionWindow(for screenID: ScreenID, context: ScreenContext, centerHorizontally: Bool = false) {
updateWindowFrame(for: screenID, context: context, centerHorizontally: centerHorizontally)
}
func updateWindowFrame(
for screenID: ScreenID,
context: ScreenContext,
contentSize: CGSize? = nil,
centerHorizontally: Bool = false
) {
guard let screen = screenLookup(screenID),
let window = windows[screenID] else {
return
}
let frame = WindowFrameCalculator.targetFrame(
screenFrame: screen.frame,
currentWindowFrame: window.frame,
notchState: context.notchState,
contentSize: resolvedContentSize(for: context, override: contentSize),
centerHorizontally: centerHorizontally
)
guard !window.frame.equalTo(frame) else { return }
window.setFrame(frame, display: false)
}
func animatePresetResize(
for screenID: ScreenID,
context: ScreenContext,
from startSize: CGSize,
to targetSize: CGSize,
duration: TimeInterval
) {
cancelPresetResize(for: screenID)
guard startSize != targetSize else {
context.notchSize = targetSize
updateWindowFrame(for: screenID, context: context, contentSize: targetSize, centerHorizontally: true)
return
}
context.isPresetResizing = true
let startTime = CACurrentMediaTime()
let frameInterval = max(duration, presetResizeFrameInterval)
let timer = Timer(timeInterval: presetResizeFrameInterval, repeats: true) { [weak self] timer in
MainActor.assumeIsolated {
guard let self else {
timer.invalidate()
return
}
let elapsed = CACurrentMediaTime() - startTime
let progress = min(1, elapsed / frameInterval)
let easedProgress = 0.5 - (cos(.pi * progress) / 2)
let size = CGSize(
width: startSize.width + ((targetSize.width - startSize.width) * easedProgress),
height: startSize.height + ((targetSize.height - startSize.height) * easedProgress)
)
context.notchSize = size
self.updateWindowFrame(for: screenID, context: context, contentSize: size, centerHorizontally: true)
if progress >= 1 {
context.notchSize = targetSize
context.isPresetResizing = false
self.updateWindowFrame(for: screenID, context: context, contentSize: targetSize, centerHorizontally: true)
self.presetResizeTimers[screenID] = nil
timer.invalidate()
}
}
}
presetResizeTimers[screenID] = timer
RunLoop.main.add(timer, forMode: .common)
timer.fire()
}
func presentOpen(
for screenID: ScreenID,
terminalViewProvider: @escaping @MainActor () -> NSView?
) {
guard let window = windows[screenID] else { return }
window.isNotchOpen = true
updateHotkeyOpenState()
applicationActivator()
window.makeKeyAndOrderFront(nil)
focusActiveTerminal(
in: screenID,
attemptsRemaining: 12,
terminalViewProvider: terminalViewProvider
)
}
func focusActiveTerminal(
for screenID: ScreenID,
terminalViewProvider: @escaping @MainActor () -> NSView?
) {
focusActiveTerminal(
in: screenID,
attemptsRemaining: 12,
terminalViewProvider: terminalViewProvider
)
}
func presentClose(for screenID: ScreenID) {
guard let window = windows[screenID] else { return }
window.isNotchOpen = false
updateHotkeyOpenState()
}
func cleanupAllWindows() {
for timer in presetResizeTimers.values {
timer.invalidate()
}
presetResizeTimers.removeAll()
for window in windows.values {
window.orderOut(nil)
window.close()
}
windows.removeAll()
updateHotkeyOpenState()
}
private func focusActiveTerminal(
in screenID: ScreenID,
attemptsRemaining: Int,
terminalViewProvider: @escaping @MainActor () -> NSView?
) {
guard let window = windows[screenID],
let terminalView = terminalViewProvider() else {
return
}
if terminalView.window === window {
window.makeFirstResponder(terminalView)
return
}
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in
Task { @MainActor in
self?.focusActiveTerminal(
in: screenID,
attemptsRemaining: attemptsRemaining - 1,
terminalViewProvider: terminalViewProvider
)
}
}
}
private func cancelPresetResize(for screenID: ScreenID) {
presetResizeTimers[screenID]?.invalidate()
presetResizeTimers[screenID] = nil
}
private func resolvedContentSize(for context: ScreenContext, override: CGSize?) -> CGSize {
if let override {
return override
}
return context.notchState == .open ? context.notchSize : context.openNotchSize
}
private func updateHotkeyOpenState() {
hotkeyOpenStateHandler(windows.values.contains(where: \.isNotchOpen))
}
}

View File

@@ -0,0 +1,165 @@
import Foundation
import CoreGraphics
struct AppSettings: Equatable, Codable {
var display: DisplaySettings
var behavior: BehaviorSettings
var appearance: AppearanceSettings
var animation: AnimationSettings
var terminal: TerminalSettings
var hotkeys: HotkeySettings
static let `default` = AppSettings(
display: DisplaySettings(
showOnAllDisplays: NotchSettings.Defaults.showOnAllDisplays,
showMenuBarIcon: NotchSettings.Defaults.showMenuBarIcon,
launchAtLogin: NotchSettings.Defaults.launchAtLogin,
notchHeightMode: NotchSettings.Defaults.notchHeightMode,
notchHeight: NotchSettings.Defaults.notchHeight,
nonNotchHeightMode: NotchSettings.Defaults.nonNotchHeightMode,
nonNotchHeight: NotchSettings.Defaults.nonNotchHeight,
openWidth: NotchSettings.Defaults.openWidth,
openHeight: NotchSettings.Defaults.openHeight
),
behavior: BehaviorSettings(
openNotchOnHover: NotchSettings.Defaults.openNotchOnHover,
minimumHoverDuration: NotchSettings.Defaults.minimumHoverDuration,
enableGestures: NotchSettings.Defaults.enableGestures,
gestureSensitivity: NotchSettings.Defaults.gestureSensitivity
),
appearance: AppearanceSettings(
enableShadow: NotchSettings.Defaults.enableShadow,
shadowRadius: NotchSettings.Defaults.shadowRadius,
shadowOpacity: NotchSettings.Defaults.shadowOpacity,
cornerRadiusScaling: NotchSettings.Defaults.cornerRadiusScaling,
notchOpacity: NotchSettings.Defaults.notchOpacity,
blurRadius: NotchSettings.Defaults.blurRadius
),
animation: AnimationSettings(
openSpringResponse: NotchSettings.Defaults.openSpringResponse,
openSpringDamping: NotchSettings.Defaults.openSpringDamping,
closeSpringResponse: NotchSettings.Defaults.closeSpringResponse,
closeSpringDamping: NotchSettings.Defaults.closeSpringDamping,
hoverSpringResponse: NotchSettings.Defaults.hoverSpringResponse,
hoverSpringDamping: NotchSettings.Defaults.hoverSpringDamping,
resizeAnimationDuration: NotchSettings.Defaults.resizeAnimationDuration
),
terminal: TerminalSettings(
fontSize: NotchSettings.Defaults.terminalFontSize,
shellPath: NotchSettings.Defaults.terminalShell,
themeRawValue: NotchSettings.Defaults.terminalTheme,
sizePresetsJSON: NotchSettings.Defaults.terminalSizePresets
),
hotkeys: HotkeySettings(
toggle: .cmdReturn,
newTab: .cmdT,
closeTab: .cmdW,
nextTab: .cmdShiftRB,
previousTab: .cmdShiftLB,
nextWorkspace: .cmdShiftDown,
previousWorkspace: .cmdShiftUp,
detachTab: .cmdD
)
)
}
extension AppSettings {
struct DisplaySettings: Equatable, Codable {
var showOnAllDisplays: Bool
var showMenuBarIcon: Bool
var launchAtLogin: Bool
var notchHeightMode: Int
var notchHeight: Double
var nonNotchHeightMode: Int
var nonNotchHeight: Double
var openWidth: Double
var openHeight: Double
}
struct BehaviorSettings: Equatable, Codable {
var openNotchOnHover: Bool
var minimumHoverDuration: Double
var enableGestures: Bool
var gestureSensitivity: Double
}
struct AppearanceSettings: Equatable, Codable {
var enableShadow: Bool
var shadowRadius: Double
var shadowOpacity: Double
var cornerRadiusScaling: Bool
var notchOpacity: Double
var blurRadius: Double
}
struct AnimationSettings: Equatable, Codable {
var openSpringResponse: Double
var openSpringDamping: Double
var closeSpringResponse: Double
var closeSpringDamping: Double
var hoverSpringResponse: Double
var hoverSpringDamping: Double
var resizeAnimationDuration: Double
}
struct TerminalSettings: Equatable, Codable {
var fontSize: Double
var shellPath: String
var themeRawValue: String
var sizePresetsJSON: String
var theme: TerminalTheme {
TerminalTheme.resolve(themeRawValue)
}
var sizePresets: [TerminalSizePreset] {
TerminalSizePresetStore.decodePresets(from: sizePresetsJSON) ?? TerminalSizePresetStore.loadDefaults()
}
}
struct HotkeySettings: Equatable, Codable {
var toggle: HotkeyBinding
var newTab: HotkeyBinding
var closeTab: HotkeyBinding
var nextTab: HotkeyBinding
var previousTab: HotkeyBinding
var nextWorkspace: HotkeyBinding
var previousWorkspace: HotkeyBinding
var detachTab: HotkeyBinding
}
}
extension AppSettings.DisplaySettings {
struct LayoutSignature: Equatable {
var notchHeightMode: Int
var notchHeight: Double
var nonNotchHeightMode: Int
var nonNotchHeight: Double
var openWidth: Double
var openHeight: Double
}
var layoutSignature: LayoutSignature {
LayoutSignature(
notchHeightMode: notchHeightMode,
notchHeight: notchHeight,
nonNotchHeightMode: nonNotchHeightMode,
nonNotchHeight: nonNotchHeight,
openWidth: openWidth,
openHeight: openHeight
)
}
}
struct TerminalSessionConfiguration: Equatable {
var fontSize: CGFloat
var theme: TerminalTheme
var shellPath: String
}
@MainActor
protocol TerminalSessionConfigurationProviding: AnyObject {
var terminalSessionConfiguration: TerminalSessionConfiguration { get }
var hotkeySettings: AppSettings.HotkeySettings { get }
var terminalSizePresets: [TerminalSizePreset] { get }
}

View File

@@ -0,0 +1,74 @@
import Foundation
import Combine
@MainActor
final class AppSettingsController: ObservableObject, TerminalSessionConfigurationProviding {
static let shared = AppSettingsController(
store: UserDefaultsAppSettingsStore(),
observeExternalChanges: true
)
@Published private(set) var settings: AppSettings
private let store: any AppSettingsStoreType
private let notificationCenter: NotificationCenter
private var defaultsObserver: NSObjectProtocol?
init(
store: any AppSettingsStoreType,
observeExternalChanges: Bool = false,
notificationCenter: NotificationCenter = .default
) {
self.store = store
self.notificationCenter = notificationCenter
self.settings = store.load()
if observeExternalChanges {
defaultsObserver = notificationCenter.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.refresh()
}
}
}
}
deinit {
if let defaultsObserver {
notificationCenter.removeObserver(defaultsObserver)
}
}
var terminalSessionConfiguration: TerminalSessionConfiguration {
TerminalSessionConfiguration(
fontSize: CGFloat(settings.terminal.fontSize),
theme: settings.terminal.theme,
shellPath: settings.terminal.shellPath
)
}
var hotkeySettings: AppSettings.HotkeySettings {
settings.hotkeys
}
var terminalSizePresets: [TerminalSizePreset] {
settings.terminal.sizePresets
}
func refresh() {
let loaded = store.load()
guard loaded != settings else { return }
settings = loaded
}
func update(_ mutate: (inout AppSettings) -> Void) {
var updated = settings
mutate(&updated)
guard updated != settings else { return }
settings = updated
store.save(updated)
}
}

View File

@@ -0,0 +1,140 @@
import Foundation
protocol AppSettingsStoreType {
func load() -> AppSettings
func save(_ settings: AppSettings)
}
struct UserDefaultsAppSettingsStore: AppSettingsStoreType {
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func load() -> AppSettings {
AppSettings(
display: .init(
showOnAllDisplays: bool(NotchSettings.Keys.showOnAllDisplays, default: NotchSettings.Defaults.showOnAllDisplays),
showMenuBarIcon: bool(NotchSettings.Keys.showMenuBarIcon, default: NotchSettings.Defaults.showMenuBarIcon),
launchAtLogin: bool(NotchSettings.Keys.launchAtLogin, default: NotchSettings.Defaults.launchAtLogin),
notchHeightMode: integer(NotchSettings.Keys.notchHeightMode, default: NotchSettings.Defaults.notchHeightMode),
notchHeight: double(NotchSettings.Keys.notchHeight, default: NotchSettings.Defaults.notchHeight),
nonNotchHeightMode: integer(NotchSettings.Keys.nonNotchHeightMode, default: NotchSettings.Defaults.nonNotchHeightMode),
nonNotchHeight: double(NotchSettings.Keys.nonNotchHeight, default: NotchSettings.Defaults.nonNotchHeight),
openWidth: double(NotchSettings.Keys.openWidth, default: NotchSettings.Defaults.openWidth),
openHeight: double(NotchSettings.Keys.openHeight, default: NotchSettings.Defaults.openHeight)
),
behavior: .init(
openNotchOnHover: bool(NotchSettings.Keys.openNotchOnHover, default: NotchSettings.Defaults.openNotchOnHover),
minimumHoverDuration: double(NotchSettings.Keys.minimumHoverDuration, default: NotchSettings.Defaults.minimumHoverDuration),
enableGestures: bool(NotchSettings.Keys.enableGestures, default: NotchSettings.Defaults.enableGestures),
gestureSensitivity: double(NotchSettings.Keys.gestureSensitivity, default: NotchSettings.Defaults.gestureSensitivity)
),
appearance: .init(
enableShadow: bool(NotchSettings.Keys.enableShadow, default: NotchSettings.Defaults.enableShadow),
shadowRadius: double(NotchSettings.Keys.shadowRadius, default: NotchSettings.Defaults.shadowRadius),
shadowOpacity: double(NotchSettings.Keys.shadowOpacity, default: NotchSettings.Defaults.shadowOpacity),
cornerRadiusScaling: bool(NotchSettings.Keys.cornerRadiusScaling, default: NotchSettings.Defaults.cornerRadiusScaling),
notchOpacity: double(NotchSettings.Keys.notchOpacity, default: NotchSettings.Defaults.notchOpacity),
blurRadius: double(NotchSettings.Keys.blurRadius, default: NotchSettings.Defaults.blurRadius)
),
animation: .init(
openSpringResponse: double(NotchSettings.Keys.openSpringResponse, default: NotchSettings.Defaults.openSpringResponse),
openSpringDamping: double(NotchSettings.Keys.openSpringDamping, default: NotchSettings.Defaults.openSpringDamping),
closeSpringResponse: double(NotchSettings.Keys.closeSpringResponse, default: NotchSettings.Defaults.closeSpringResponse),
closeSpringDamping: double(NotchSettings.Keys.closeSpringDamping, default: NotchSettings.Defaults.closeSpringDamping),
hoverSpringResponse: double(NotchSettings.Keys.hoverSpringResponse, default: NotchSettings.Defaults.hoverSpringResponse),
hoverSpringDamping: double(NotchSettings.Keys.hoverSpringDamping, default: NotchSettings.Defaults.hoverSpringDamping),
resizeAnimationDuration: double(NotchSettings.Keys.resizeAnimationDuration, default: NotchSettings.Defaults.resizeAnimationDuration)
),
terminal: .init(
fontSize: double(NotchSettings.Keys.terminalFontSize, default: NotchSettings.Defaults.terminalFontSize),
shellPath: string(NotchSettings.Keys.terminalShell, default: NotchSettings.Defaults.terminalShell),
themeRawValue: string(NotchSettings.Keys.terminalTheme, default: NotchSettings.Defaults.terminalTheme),
sizePresetsJSON: string(NotchSettings.Keys.terminalSizePresets, default: NotchSettings.Defaults.terminalSizePresets)
),
hotkeys: .init(
toggle: hotkey(NotchSettings.Keys.hotkeyToggle, default: .cmdReturn),
newTab: hotkey(NotchSettings.Keys.hotkeyNewTab, default: .cmdT),
closeTab: hotkey(NotchSettings.Keys.hotkeyCloseTab, default: .cmdW),
nextTab: hotkey(NotchSettings.Keys.hotkeyNextTab, default: .cmdShiftRB),
previousTab: hotkey(NotchSettings.Keys.hotkeyPreviousTab, default: .cmdShiftLB),
nextWorkspace: hotkey(NotchSettings.Keys.hotkeyNextWorkspace, default: .cmdShiftDown),
previousWorkspace: hotkey(NotchSettings.Keys.hotkeyPreviousWorkspace, default: .cmdShiftUp),
detachTab: hotkey(NotchSettings.Keys.hotkeyDetachTab, default: .cmdD)
)
)
}
func save(_ settings: AppSettings) {
defaults.set(settings.display.showOnAllDisplays, forKey: NotchSettings.Keys.showOnAllDisplays)
defaults.set(settings.display.showMenuBarIcon, forKey: NotchSettings.Keys.showMenuBarIcon)
defaults.set(settings.display.launchAtLogin, forKey: NotchSettings.Keys.launchAtLogin)
defaults.set(settings.display.notchHeightMode, forKey: NotchSettings.Keys.notchHeightMode)
defaults.set(settings.display.notchHeight, forKey: NotchSettings.Keys.notchHeight)
defaults.set(settings.display.nonNotchHeightMode, forKey: NotchSettings.Keys.nonNotchHeightMode)
defaults.set(settings.display.nonNotchHeight, forKey: NotchSettings.Keys.nonNotchHeight)
defaults.set(settings.display.openWidth, forKey: NotchSettings.Keys.openWidth)
defaults.set(settings.display.openHeight, forKey: NotchSettings.Keys.openHeight)
defaults.set(settings.behavior.openNotchOnHover, forKey: NotchSettings.Keys.openNotchOnHover)
defaults.set(settings.behavior.minimumHoverDuration, forKey: NotchSettings.Keys.minimumHoverDuration)
defaults.set(settings.behavior.enableGestures, forKey: NotchSettings.Keys.enableGestures)
defaults.set(settings.behavior.gestureSensitivity, forKey: NotchSettings.Keys.gestureSensitivity)
defaults.set(settings.appearance.enableShadow, forKey: NotchSettings.Keys.enableShadow)
defaults.set(settings.appearance.shadowRadius, forKey: NotchSettings.Keys.shadowRadius)
defaults.set(settings.appearance.shadowOpacity, forKey: NotchSettings.Keys.shadowOpacity)
defaults.set(settings.appearance.cornerRadiusScaling, forKey: NotchSettings.Keys.cornerRadiusScaling)
defaults.set(settings.appearance.notchOpacity, forKey: NotchSettings.Keys.notchOpacity)
defaults.set(settings.appearance.blurRadius, forKey: NotchSettings.Keys.blurRadius)
defaults.set(settings.animation.openSpringResponse, forKey: NotchSettings.Keys.openSpringResponse)
defaults.set(settings.animation.openSpringDamping, forKey: NotchSettings.Keys.openSpringDamping)
defaults.set(settings.animation.closeSpringResponse, forKey: NotchSettings.Keys.closeSpringResponse)
defaults.set(settings.animation.closeSpringDamping, forKey: NotchSettings.Keys.closeSpringDamping)
defaults.set(settings.animation.hoverSpringResponse, forKey: NotchSettings.Keys.hoverSpringResponse)
defaults.set(settings.animation.hoverSpringDamping, forKey: NotchSettings.Keys.hoverSpringDamping)
defaults.set(settings.animation.resizeAnimationDuration, forKey: NotchSettings.Keys.resizeAnimationDuration)
defaults.set(settings.terminal.fontSize, forKey: NotchSettings.Keys.terminalFontSize)
defaults.set(settings.terminal.shellPath, forKey: NotchSettings.Keys.terminalShell)
defaults.set(settings.terminal.themeRawValue, forKey: NotchSettings.Keys.terminalTheme)
defaults.set(settings.terminal.sizePresetsJSON, forKey: NotchSettings.Keys.terminalSizePresets)
defaults.set(settings.hotkeys.toggle.toJSON(), forKey: NotchSettings.Keys.hotkeyToggle)
defaults.set(settings.hotkeys.newTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNewTab)
defaults.set(settings.hotkeys.closeTab.toJSON(), forKey: NotchSettings.Keys.hotkeyCloseTab)
defaults.set(settings.hotkeys.nextTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNextTab)
defaults.set(settings.hotkeys.previousTab.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousTab)
defaults.set(settings.hotkeys.nextWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyNextWorkspace)
defaults.set(settings.hotkeys.previousWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousWorkspace)
defaults.set(settings.hotkeys.detachTab.toJSON(), forKey: NotchSettings.Keys.hotkeyDetachTab)
}
private func bool(_ key: String, default defaultValue: Bool) -> Bool {
guard defaults.object(forKey: key) != nil else { return defaultValue }
return defaults.bool(forKey: key)
}
private func double(_ key: String, default defaultValue: Double) -> Double {
guard defaults.object(forKey: key) != nil else { return defaultValue }
return defaults.double(forKey: key)
}
private func integer(_ key: String, default defaultValue: Int) -> Int {
guard defaults.object(forKey: key) != nil else { return defaultValue }
return defaults.integer(forKey: key)
}
private func string(_ key: String, default defaultValue: String) -> String {
defaults.string(forKey: key) ?? defaultValue
}
private func hotkey(_ key: String, default defaultValue: HotkeyBinding) -> HotkeyBinding {
guard let json = defaults.string(forKey: key),
let binding = HotkeyBinding.fromJSON(json) else { return defaultValue }
return binding
}
}

View File

@@ -0,0 +1,115 @@
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, Hashable {
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 cmdShiftDown = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 125)
static let cmdShiftUp = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 126)
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? {
guard let keyCode = keyCode(forDigit: digit) else { return nil }
return HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: keyCode)
}
static func keyCode(forDigit digit: Int) -> UInt16? {
switch digit {
case 0: return 29
case 1: return 18
case 2: return 19
case 3: return 20
case 4: return 21
case 5: return 23
case 6: return 22
case 7: return 26
case 8: return 28
case 9: return 25
default: return nil
}
}
}

View File

@@ -0,0 +1,189 @@
import Combine
import SwiftUI
@MainActor
protocol ScreenRegistryType: AnyObject {
func allScreens() -> [ScreenContext]
func screenContext(for id: ScreenID) -> ScreenContext?
func activeScreenID() -> ScreenID?
func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID?
@discardableResult
func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID?
func releaseWorkspacePresentation(for screenID: ScreenID)
}
@MainActor
protocol NotchPresentationHost: AnyObject {
func canPresentNotch(for screenID: ScreenID) -> Bool
func performOpenPresentation(for screenID: ScreenID)
func performClosePresentation(for screenID: ScreenID)
}
protocol SchedulerType {
@MainActor
func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable
}
struct TaskScheduler: SchedulerType {
@MainActor
func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable {
let task = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
guard !Task.isCancelled else { return }
action()
}
return AnyCancellable {
task.cancel()
}
}
}
@MainActor
final class NotchOrchestrator {
private let screenRegistry: any ScreenRegistryType
private weak var host: (any NotchPresentationHost)?
private let settingsController: AppSettingsController
private let scheduler: any SchedulerType
private var hoverOpenTasks: [ScreenID: AnyCancellable] = [:]
private var closeTransitionTasks: [ScreenID: AnyCancellable] = [:]
init(
screenRegistry: any ScreenRegistryType,
host: any NotchPresentationHost,
settingsController: AppSettingsController? = nil,
scheduler: (any SchedulerType)? = nil
) {
self.screenRegistry = screenRegistry
self.host = host
self.settingsController = settingsController ?? AppSettingsController.shared
self.scheduler = scheduler ?? TaskScheduler()
}
func toggleOnActiveScreen() {
guard let screenID = screenRegistry.activeScreenID(),
host?.canPresentNotch(for: screenID) == true,
let context = screenRegistry.screenContext(for: screenID) else {
return
}
if context.notchState == .open {
close(screenID: screenID)
} else {
open(screenID: screenID)
}
}
func open(screenID: ScreenID) {
guard host?.canPresentNotch(for: screenID) == true,
let context = screenRegistry.screenContext(for: screenID) else {
return
}
if let presentingScreenID = screenRegistry.presentingScreenID(for: context.workspaceID),
presentingScreenID != screenID {
close(screenID: presentingScreenID)
}
cancelHoverOpen(for: screenID)
cancelCloseTransition(for: screenID)
context.cancelCloseTransition()
withAnimation(context.openAnimation) {
context.open()
}
_ = screenRegistry.claimWorkspacePresentation(for: screenID)
host?.performOpenPresentation(for: screenID)
}
func close(screenID: ScreenID) {
guard let context = screenRegistry.screenContext(for: screenID) else { return }
cancelHoverOpen(for: screenID)
cancelCloseTransition(for: screenID)
context.beginCloseTransition()
closeTransitionTasks[screenID] = scheduler.schedule(after: context.closeInteractionLockDuration) { [weak self] in
self?.finishCloseTransition(for: screenID)
}
withAnimation(context.closeAnimation) {
context.close()
}
screenRegistry.releaseWorkspacePresentation(for: screenID)
host?.performClosePresentation(for: screenID)
}
func handleHoverChange(_ hovering: Bool, for screenID: ScreenID) {
guard let context = screenRegistry.screenContext(for: screenID) else { return }
context.isHovering = hovering
if hovering {
scheduleHoverOpenIfNeeded(for: screenID)
} else {
cancelHoverOpen(for: screenID)
context.clearHoverOpenSuppression()
}
}
func cancelAllPendingWork() {
for task in hoverOpenTasks.values {
task.cancel()
}
for task in closeTransitionTasks.values {
task.cancel()
}
hoverOpenTasks.removeAll()
closeTransitionTasks.removeAll()
}
private func scheduleHoverOpenIfNeeded(for screenID: ScreenID) {
cancelHoverOpen(for: screenID)
guard let context = screenRegistry.screenContext(for: screenID) else { return }
guard settingsController.settings.behavior.openNotchOnHover,
context.notchState == .closed,
!context.isCloseTransitionActive,
!context.suppressHoverOpenUntilHoverExit,
context.isHovering else {
return
}
hoverOpenTasks[screenID] = scheduler.schedule(after: settingsController.settings.behavior.minimumHoverDuration) { [weak self] in
guard let self,
let context = self.screenRegistry.screenContext(for: screenID),
context.isHovering,
context.notchState == .closed,
!context.isCloseTransitionActive,
!context.suppressHoverOpenUntilHoverExit else {
return
}
self.hoverOpenTasks[screenID] = nil
self.open(screenID: screenID)
}
}
private func finishCloseTransition(for screenID: ScreenID) {
closeTransitionTasks[screenID] = nil
guard let context = screenRegistry.screenContext(for: screenID) else { return }
context.endCloseTransition()
scheduleHoverOpenIfNeeded(for: screenID)
}
private func cancelHoverOpen(for screenID: ScreenID) {
hoverOpenTasks[screenID]?.cancel()
hoverOpenTasks[screenID] = nil
}
private func cancelCloseTransition(for screenID: ScreenID) {
closeTransitionTasks[screenID]?.cancel()
closeTransitionTasks[screenID] = nil
}
}

View File

@@ -0,0 +1,276 @@
import Foundation
import AppKit
/// 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"
static let launchAtLogin = "launchAtLogin"
// 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"
static let resizeAnimationDuration = "resizeAnimationDuration"
// Behavior
static let enableGestures = "enableGestures"
static let gestureSensitivity = "gestureSensitivity"
// Terminal
static let terminalFontSize = "terminalFontSize"
static let terminalShell = "terminalShell"
static let terminalTheme = "terminalTheme"
static let terminalSizePresets = "terminalSizePresets"
static let workspaceSummaries = "workspaceSummaries"
static let screenAssignments = "screenAssignments"
// 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 hotkeyNextWorkspace = "hotkey_nextWorkspace"
static let hotkeyPreviousWorkspace = "hotkey_previousWorkspace"
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 launchAtLogin: Bool = false
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 resizeAnimationDuration: Double = 0.42
static let enableGestures: Bool = true
static let gestureSensitivity: Double = 0.5
static let terminalFontSize: Double = 13
static let terminalShell: String = ""
static let terminalTheme: String = TerminalTheme.terminalApp.rawValue
static let terminalSizePresets: String = TerminalSizePresetStore.defaultPresetsJSON()
// 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 hotkeyNextWorkspace: String = HotkeyBinding.cmdShiftDown.toJSON()
static let hotkeyPreviousWorkspace: String = HotkeyBinding.cmdShiftUp.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.launchAtLogin: Defaults.launchAtLogin,
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.resizeAnimationDuration: Defaults.resizeAnimationDuration,
Keys.enableGestures: Defaults.enableGestures,
Keys.gestureSensitivity: Defaults.gestureSensitivity,
Keys.terminalFontSize: Defaults.terminalFontSize,
Keys.terminalShell: Defaults.terminalShell,
Keys.terminalTheme: Defaults.terminalTheme,
Keys.terminalSizePresets: Defaults.terminalSizePresets,
Keys.hotkeyToggle: Defaults.hotkeyToggle,
Keys.hotkeyNewTab: Defaults.hotkeyNewTab,
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
Keys.hotkeyNextWorkspace: Defaults.hotkeyNextWorkspace,
Keys.hotkeyPreviousWorkspace: Defaults.hotkeyPreviousWorkspace,
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"
}
}
}
struct TerminalSizePreset: Codable, Equatable, Identifiable {
var id: UUID
var name: String
var width: Double
var height: Double
var hotkey: HotkeyBinding?
init(
id: UUID = UUID(),
name: String,
width: Double,
height: Double,
hotkey: HotkeyBinding? = nil
) {
self.id = id
self.name = name
self.width = width
self.height = height
self.hotkey = hotkey
}
var size: CGSize {
CGSize(width: width, height: height)
}
}
enum TerminalSizePresetStore {
static func load() -> [TerminalSizePreset] {
let defaults = UserDefaults.standard
guard let json = defaults.string(forKey: NotchSettings.Keys.terminalSizePresets),
let presets = decodePresets(from: json) else {
return defaultPresets()
}
return presets
}
static func save(_ presets: [TerminalSizePreset]) {
UserDefaults.standard.set(encodePresets(presets), forKey: NotchSettings.Keys.terminalSizePresets)
}
static func reset() {
save(defaultPresets())
}
static func loadDefaults() -> [TerminalSizePreset] {
defaultPresets()
}
static func defaultPresetsJSON() -> String {
encodePresets(defaultPresets())
}
static func suggestedHotkey(for presets: [TerminalSizePreset]) -> HotkeyBinding? {
let used = Set(presets.compactMap(\.hotkey))
for digit in 1...9 {
guard let candidate = HotkeyBinding.cmdShiftDigit(digit) else { continue }
if !used.contains(candidate) {
return candidate
}
}
return nil
}
private static func defaultPresets() -> [TerminalSizePreset] {
[
TerminalSizePreset(name: "Compact", width: 480, height: 220, hotkey: HotkeyBinding.cmdShiftDigit(1)),
TerminalSizePreset(name: "Default", width: 640, height: 350, hotkey: HotkeyBinding.cmdShiftDigit(2)),
TerminalSizePreset(name: "Large", width: 900, height: 500, hotkey: HotkeyBinding.cmdShiftDigit(3)),
]
}
static func decodePresets(from json: String) -> [TerminalSizePreset]? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode([TerminalSizePreset].self, from: data)
}
static func encodePresets(_ presets: [TerminalSizePreset]) -> String {
guard let data = try? JSONEncoder().encode(presets),
let json = String(data: data, encoding: .utf8) else {
return "[]"
}
return json
}
}

View 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
}

View File

@@ -0,0 +1,222 @@
import AppKit
import SwiftUI
typealias ScreenID = String
/// Observable screen-local UI state for one physical display.
@MainActor
final class ScreenContext: ObservableObject, Identifiable {
private static let minimumOpenWidth: CGFloat = 320
private static let minimumOpenHeight: CGFloat = 140
private static let windowHorizontalPadding: CGFloat = 40
private static let windowVerticalPadding: CGFloat = 20
let id: ScreenID
@Published var workspaceID: WorkspaceID
@Published var notchState: NotchState = .closed
@Published var notchSize: CGSize
@Published var closedNotchSize: CGSize
@Published var isHovering = false
@Published var isCloseTransitionActive = false
@Published var suppressHoverOpenUntilHoverExit = false
@Published var isUserResizing = false
@Published var isPresetResizing = false
@Published private(set) var suppressCloseOnFocusLoss = false
var requestOpen: (() -> Void)?
var requestClose: (() -> Void)?
var requestWindowResize: (() -> Void)?
var requestTerminalFocus: (() -> Void)?
private let settingsController: AppSettingsController
private let screenProvider: @MainActor (ScreenID) -> NSScreen?
init(
id: ScreenID,
workspaceID: WorkspaceID,
settingsController: AppSettingsController? = nil,
screenProvider: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in
NSScreen.screens.first { $0.displayUUID == screenID }
}
) {
self.id = id
self.workspaceID = workspaceID
self.settingsController = settingsController ?? AppSettingsController.shared
self.screenProvider = screenProvider
let closed = Self.resolveClosedNotchSize(
for: id,
using: self.settingsController.settings.display,
screenProvider: screenProvider
)
self.closedNotchSize = closed
self.notchSize = closed
}
var openAnimation: Animation {
let animation = settingsController.settings.animation
return .spring(
response: animation.openSpringResponse,
dampingFraction: animation.openSpringDamping
)
}
var closeAnimation: Animation {
let animation = settingsController.settings.animation
return .spring(
response: animation.closeSpringResponse,
dampingFraction: animation.closeSpringDamping
)
}
var openAnimationDuration: TimeInterval {
max(0.05, settingsController.settings.animation.resizeAnimationDuration)
}
func open() {
notchSize = openNotchSize
notchState = .open
}
func close() {
refreshClosedSize()
notchSize = closedNotchSize
notchState = .closed
}
func updateWorkspace(id: WorkspaceID) {
guard workspaceID != id else { return }
workspaceID = id
}
func refreshClosedSize() {
closedNotchSize = Self.resolveClosedNotchSize(
for: id,
using: settingsController.settings.display,
screenProvider: screenProvider
)
}
var openNotchSize: CGSize {
let display = settingsController.settings.display
return clampedOpenSize(
CGSize(width: display.openWidth, height: display.openHeight)
)
}
func beginInteractiveResize() {
isUserResizing = true
}
func resizeOpenNotch(to proposedSize: CGSize) {
let clampedSize = clampedOpenSize(proposedSize)
if notchState == .open {
notchSize = clampedSize
}
requestWindowResize?()
}
func endInteractiveResize() {
if notchState == .open {
settingsController.update {
$0.display.openWidth = notchSize.width
$0.display.openHeight = notchSize.height
}
}
isUserResizing = false
}
func applySizePreset(_ preset: TerminalSizePreset, notifyWindowResize: Bool = true) {
setOpenSize(preset.size, notifyWindowResize: notifyWindowResize)
}
@discardableResult
func setStoredOpenSize(_ proposedSize: CGSize) -> CGSize {
let clampedSize = clampedOpenSize(proposedSize)
settingsController.update {
$0.display.openWidth = clampedSize.width
$0.display.openHeight = clampedSize.height
}
return clampedSize
}
@discardableResult
func setOpenSize(_ proposedSize: CGSize, notifyWindowResize: Bool) -> CGSize {
let clampedSize = setStoredOpenSize(proposedSize)
if notchState == .open {
notchSize = clampedSize
}
if notifyWindowResize {
requestWindowResize?()
}
return clampedSize
}
private func clampedOpenSize(_ size: CGSize) -> CGSize {
CGSize(
width: size.width.clamped(to: Self.minimumOpenWidth...maximumAllowedWidth),
height: size.height.clamped(to: Self.minimumOpenHeight...maximumAllowedHeight)
)
}
private var maximumAllowedWidth: CGFloat {
guard let screen = resolvedScreen() ?? NSScreen.main else {
return Self.minimumOpenWidth
}
return max(Self.minimumOpenWidth, screen.frame.width - Self.windowHorizontalPadding)
}
private var maximumAllowedHeight: CGFloat {
guard let screen = resolvedScreen() ?? NSScreen.main else {
return Self.minimumOpenHeight
}
return max(Self.minimumOpenHeight, screen.frame.height - Self.windowVerticalPadding)
}
var closeInteractionLockDuration: TimeInterval {
max(settingsController.settings.animation.closeSpringResponse + 0.2, 0.35)
}
func beginCloseTransition() {
isCloseTransitionActive = true
if isHovering {
suppressHoverOpenUntilHoverExit = true
}
}
func cancelCloseTransition() {
isCloseTransitionActive = false
}
func endCloseTransition() {
isCloseTransitionActive = false
}
func clearHoverOpenSuppression() {
suppressHoverOpenUntilHoverExit = false
}
func setCloseOnFocusLossSuppressed(_ suppressed: Bool) {
suppressCloseOnFocusLoss = suppressed
}
private func resolvedScreen() -> NSScreen? {
screenProvider(id)
}
private static func resolveClosedNotchSize(
for screenID: ScreenID,
using settings: AppSettings.DisplaySettings,
screenProvider: @escaping @MainActor (ScreenID) -> NSScreen?
) -> CGSize {
let screen = screenProvider(screenID) ?? NSScreen.main
return screen?.closedNotchSize(using: settings) ?? CGSize(width: 220, height: 32)
}
}
private extension CGFloat {
func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}

View File

@@ -0,0 +1,268 @@
import AppKit
import Combine
import SwiftUI
struct ConnectedScreenSummary: Identifiable, Equatable {
let id: ScreenID
let displayName: String
let isActive: Bool
let assignedWorkspaceID: WorkspaceID
}
@MainActor
final class ScreenRegistry: ObservableObject {
static let shared = ScreenRegistry(assignmentStore: UserDefaultsScreenAssignmentStore())
@Published private(set) var screenContexts: [ScreenContext] = []
private let workspaceRegistry: WorkspaceRegistry
private let settingsController: AppSettingsController
private let assignmentStore: any ScreenAssignmentStoreType
private let connectedScreenIDsProvider: @MainActor () -> [ScreenID]
private let activeScreenIDProvider: @MainActor () -> ScreenID?
private let screenLookup: @MainActor (ScreenID) -> NSScreen?
private var contextsByID: [ScreenID: ScreenContext] = [:]
private var preferredAssignments: [ScreenID: WorkspaceID]
private var workspacePresenters: [WorkspaceID: ScreenID] = [:]
private var cancellables = Set<AnyCancellable>()
init(
workspaceRegistry: WorkspaceRegistry? = nil,
settingsController: AppSettingsController? = nil,
assignmentStore: (any ScreenAssignmentStoreType)? = nil,
initialAssignments: [ScreenID: WorkspaceID]? = nil,
connectedScreenIDsProvider: @escaping @MainActor () -> [ScreenID] = {
NSScreen.screens.map(\.displayUUID)
},
activeScreenIDProvider: @escaping @MainActor () -> ScreenID? = {
let mouseLocation = NSEvent.mouseLocation
return NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }?.displayUUID
?? NSScreen.main?.displayUUID
},
screenLookup: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in
NSScreen.screens.first { $0.displayUUID == screenID }
}
) {
let resolvedWorkspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared
let resolvedSettingsController = settingsController ?? AppSettingsController.shared
let resolvedAssignmentStore = assignmentStore ?? UserDefaultsScreenAssignmentStore()
self.workspaceRegistry = resolvedWorkspaceRegistry
self.settingsController = resolvedSettingsController
self.assignmentStore = resolvedAssignmentStore
self.preferredAssignments = initialAssignments ?? resolvedAssignmentStore.loadScreenAssignments()
self.connectedScreenIDsProvider = connectedScreenIDsProvider
self.activeScreenIDProvider = activeScreenIDProvider
self.screenLookup = screenLookup
observeWorkspaceChanges()
refreshConnectedScreens()
}
func allScreens() -> [ScreenContext] {
screenContexts
}
func screenContext(for id: ScreenID) -> ScreenContext? {
contextsByID[id]
}
func workspaceController(for screenID: ScreenID) -> WorkspaceController {
let workspaceID = contextsByID[screenID]?.workspaceID ?? workspaceRegistry.defaultWorkspaceID
return workspaceRegistry.controller(for: workspaceID) ?? workspaceRegistry.defaultWorkspaceController
}
func assignedScreenIDs(to workspaceID: WorkspaceID) -> [ScreenID] {
preferredAssignments
.filter { $0.value == workspaceID }
.map(\.key)
.sorted()
}
func assignedScreenCount(to workspaceID: WorkspaceID) -> Int {
assignedScreenIDs(to: workspaceID).count
}
func connectedScreenSummaries() -> [ConnectedScreenSummary] {
let activeScreenID = activeScreenID()
return screenContexts.enumerated().map { index, context in
ConnectedScreenSummary(
id: context.id,
displayName: resolvedDisplayName(for: context.id, fallbackIndex: index),
isActive: context.id == activeScreenID,
assignedWorkspaceID: context.workspaceID
)
}
}
func assignWorkspace(_ workspaceID: WorkspaceID, to screenID: ScreenID) {
guard workspaceRegistry.controller(for: workspaceID) != nil else { return }
let previousWorkspaceID = contextsByID[screenID]?.workspaceID ?? preferredAssignments[screenID]
preferredAssignments[screenID] = workspaceID
contextsByID[screenID]?.updateWorkspace(id: workspaceID)
if let previousWorkspaceID,
previousWorkspaceID != workspaceID,
workspacePresenters[previousWorkspaceID] == screenID {
workspacePresenters.removeValue(forKey: previousWorkspaceID)
}
persistAssignments()
}
@discardableResult
func assignActiveScreen(to workspaceID: WorkspaceID) -> ScreenID? {
guard let screenID = activeScreenID() else { return nil }
assignWorkspace(workspaceID, to: screenID)
return screenID
}
func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID? {
guard let screenID = workspacePresenters[workspaceID] else { return nil }
guard preferredAssignments[screenID] == workspaceID else {
workspacePresenters.removeValue(forKey: workspaceID)
return nil
}
return screenID
}
@discardableResult
func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID? {
guard let workspaceID = contextsByID[screenID]?.workspaceID ?? preferredAssignments[screenID] else {
return nil
}
let previousPresenter = workspacePresenters[workspaceID]
workspacePresenters[workspaceID] = screenID
return previousPresenter == screenID ? nil : previousPresenter
}
func releaseWorkspacePresentation(for screenID: ScreenID) {
workspacePresenters = workspacePresenters.filter { $0.value != screenID }
}
@discardableResult
func deleteWorkspace(
_ workspaceID: WorkspaceID,
preferredFallback preferredFallbackID: WorkspaceID? = nil
) -> WorkspaceID? {
guard workspaceRegistry.canDeleteWorkspace(id: workspaceID) else {
return nil
}
guard let fallbackWorkspaceID = workspaceRegistry.deletionFallbackWorkspaceID(
forDeleting: workspaceID,
preferredFallback: preferredFallbackID
) else {
return nil
}
workspacePresenters.removeValue(forKey: workspaceID)
for (screenID, assignedWorkspaceID) in preferredAssignments where assignedWorkspaceID == workspaceID {
preferredAssignments[screenID] = fallbackWorkspaceID
}
for context in contextsByID.values where context.workspaceID == workspaceID {
context.updateWorkspace(id: fallbackWorkspaceID)
}
guard workspaceRegistry.deleteWorkspace(id: workspaceID) else {
return nil
}
persistAssignments()
return fallbackWorkspaceID
}
func activeScreenID() -> ScreenID? {
activeScreenIDProvider() ?? screenContexts.first?.id
}
func refreshConnectedScreens() {
let connectedScreenIDs = connectedScreenIDsProvider()
let validWorkspaceIDs = Set(workspaceRegistry.allWorkspaceSummaries().map(\.id))
let defaultWorkspaceID = workspaceRegistry.defaultWorkspaceID
var nextContextsByID: [ScreenID: ScreenContext] = [:]
var nextContexts: [ScreenContext] = []
for screenID in connectedScreenIDs {
let workspaceID = resolvedWorkspaceID(
for: screenID,
validWorkspaceIDs: validWorkspaceIDs,
defaultWorkspaceID: defaultWorkspaceID
)
let context = contextsByID[screenID] ?? ScreenContext(
id: screenID,
workspaceID: workspaceID,
settingsController: settingsController,
screenProvider: screenLookup
)
context.updateWorkspace(id: workspaceID)
context.refreshClosedSize()
nextContextsByID[screenID] = context
nextContexts.append(context)
}
contextsByID = nextContextsByID
screenContexts = nextContexts
reconcileWorkspacePresenters()
persistAssignments()
}
private func resolvedWorkspaceID(
for screenID: ScreenID,
validWorkspaceIDs: Set<WorkspaceID>,
defaultWorkspaceID: WorkspaceID
) -> WorkspaceID {
guard let preferredWorkspaceID = preferredAssignments[screenID],
validWorkspaceIDs.contains(preferredWorkspaceID) else {
preferredAssignments[screenID] = defaultWorkspaceID
return defaultWorkspaceID
}
return preferredWorkspaceID
}
private func observeWorkspaceChanges() {
workspaceRegistry.$workspaceSummaries
.dropFirst()
.sink { [weak self] _ in
Task { @MainActor [weak self] in
self?.refreshConnectedScreens()
}
}
.store(in: &cancellables)
}
private func persistAssignments() {
assignmentStore.saveScreenAssignments(preferredAssignments)
}
private func reconcileWorkspacePresenters() {
let validScreenIDs = Set(contextsByID.keys)
let validAssignments = preferredAssignments
workspacePresenters = workspacePresenters.filter { workspaceID, screenID in
validScreenIDs.contains(screenID) && validAssignments[screenID] == workspaceID
}
}
private func resolvedDisplayName(for screenID: ScreenID, fallbackIndex: Int) -> String {
let fallbackName = "Screen \(fallbackIndex + 1)"
guard let screen = screenLookup(screenID) else {
return fallbackName
}
let localizedName = screen.localizedName.trimmingCharacters(in: .whitespacesAndNewlines)
return localizedName.isEmpty ? fallbackName : localizedName
}
}
extension ScreenRegistry: ScreenRegistryType {}

View File

@@ -0,0 +1,75 @@
import SwiftUI
import Combine
/// Compatibility adapter for the legacy single-workspace architecture.
/// New code should use `WorkspaceRegistry` + `WorkspaceController`.
@MainActor
class TerminalManager: ObservableObject {
static let shared = TerminalManager()
private var workspaceCancellable: AnyCancellable?
private init() {
workspaceCancellable = WorkspaceRegistry.shared.defaultWorkspaceController.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
private var workspace: WorkspaceController {
WorkspaceRegistry.shared.defaultWorkspaceController
}
var tabs: [TerminalSession] {
workspace.tabs
}
var activeTabIndex: Int {
workspace.activeTabIndex
}
var activeTab: TerminalSession? {
workspace.activeTab
}
var activeTitle: String {
workspace.activeTitle
}
func newTab() {
workspace.newTab()
}
func closeActiveTab() {
workspace.closeActiveTab()
}
func closeTab(at index: Int) {
workspace.closeTab(at: index)
}
func switchToTab(at index: Int) {
workspace.switchToTab(at: index)
}
func nextTab() {
workspace.nextTab()
}
func previousTab() {
workspace.previousTab()
}
func detachActiveTab() -> TerminalSession? {
workspace.detachActiveTab()
}
func updateAllFontSizes(_ size: CGFloat) {
workspace.updateAllFontSizes(size)
}
func updateAllThemes(_ theme: TerminalTheme) {
workspace.updateAllThemes(theme)
}
}

View File

@@ -0,0 +1,165 @@
import AppKit
import SwiftTerm
import Combine
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
@MainActor
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @preconcurrency TerminalViewDelegate {
let id = UUID()
let terminalView: TerminalView
private var process: LocalProcess?
private var keyEventMonitor: Any?
private let backgroundColor = NSColor.black
private let configuredShellPath: String
@Published var title: String = "shell"
@Published var isRunning: Bool = true
@Published var currentDirectory: String?
init(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) {
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
configuredShellPath = shellPath
super.init()
terminalView.terminalDelegate = self
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
terminalView.font = font
applyTheme(theme)
installCommandArrowMonitor()
startShell()
}
deinit {
if let keyEventMonitor {
NSEvent.removeMonitor(keyEventMonitor)
}
}
// MARK: - Shell management
private func startShell() {
let shellPath = resolveShell()
let shellName = (shellPath as NSString).lastPathComponent
let loginExecName = "-\(shellName)"
let proc = LocalProcess(delegate: self)
// Launch as a login shell so user startup files initialize PATH/tools.
proc.startProcess(
executable: shellPath,
args: ["-l"],
environment: nil,
execName: loginExecName,
currentDirectory: NSHomeDirectory()
)
process = proc
title = shellName
}
private func resolveShell() -> String {
let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines)
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
return custom
}
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
}
private func installCommandArrowMonitor() {
keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
guard let window = self.terminalView.window else { return event }
guard event.window === window else { return event }
guard window.firstResponder === self.terminalView else { return event }
guard let sequence = TerminalCommandArrowBehavior.sequence(
for: event.modifierFlags,
keyCode: event.keyCode,
applicationCursor: self.terminalView.getTerminal().applicationCursor
) else {
return event
}
self.terminalView.send(data: sequence[...])
return nil
}
}
func updateFontSize(_ size: CGFloat) {
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
func applyTheme(_ theme: TerminalTheme) {
// Keep the notch visually consistent while swapping the terminal's
// default foreground color and ANSI palette for command output.
terminalView.nativeBackgroundColor = backgroundColor
terminalView.nativeForegroundColor = theme.foregroundColor
terminalView.installColors(theme.ansiColors)
}
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>) {}
}

View File

@@ -0,0 +1,117 @@
import AppKit
import SwiftTerm
enum TerminalTheme: String, CaseIterable, Identifiable {
case terminalApp
case xterm
case solarizedDark
case dracula
case nord
var id: String { rawValue }
var label: String {
switch self {
case .terminalApp: return "Classic"
case .xterm: return "Xterm"
case .solarizedDark:return "Solarized Dark"
case .dracula: return "Dracula"
case .nord: return "Nord"
}
}
var detail: String {
switch self {
case .terminalApp:
return "Matches the app's current terminal palette."
case .xterm:
return "Traditional xterm-style ANSI colors."
case .solarizedDark:
return "Low-contrast dark palette with Solarized accents."
case .dracula:
return "Higher-contrast dark palette with vivid ANSI colors."
case .nord:
return "Cool blue-grey palette with restrained accents."
}
}
var foregroundColor: NSColor {
switch self {
case .terminalApp:
return Self.nsColor(0xE5E5E5)
case .xterm:
return Self.nsColor(0xE5E5E5)
case .solarizedDark:
return Self.nsColor(0x839496)
case .dracula:
return Self.nsColor(0xF8F8F2)
case .nord:
return Self.nsColor(0xD8DEE9)
}
}
var ansiColors: [Color] {
switch self {
case .terminalApp:
return Self.palette([
0x000000, 0xC23621, 0x25BC24, 0xADAD27,
0x492EE1, 0xD338D3, 0x33BBC8, 0xCBCCCD,
0x818383, 0xFC391F, 0x31E722, 0xEAEC23,
0x5833FF, 0xF935F8, 0x14F0F0, 0xE9EBEB
])
case .xterm:
return Self.palette([
0x000000, 0xCD0000, 0x00CD00, 0xCDCD00,
0x0000EE, 0xCD00CD, 0x00CDCD, 0xE5E5E5,
0x7F7F7F, 0xFF0000, 0x00FF00, 0xFFFF00,
0x5C5CFF, 0xFF00FF, 0x00FFFF, 0xFFFFFF
])
case .solarizedDark:
return Self.palette([
0x073642, 0xDC322F, 0x859900, 0xB58900,
0x268BD2, 0xD33682, 0x2AA198, 0xEEE8D5,
0x002B36, 0xCB4B16, 0x586E75, 0x657B83,
0x839496, 0x6C71C4, 0x93A1A1, 0xFDF6E3
])
case .dracula:
return Self.palette([
0x21222C, 0xFF5555, 0x50FA7B, 0xF1FA8C,
0xBD93F9, 0xFF79C6, 0x8BE9FD, 0xF8F8F2,
0x6272A4, 0xFF6E6E, 0x69FF94, 0xFFFFA5,
0xD6ACFF, 0xFF92DF, 0xA4FFFF, 0xFFFFFF
])
case .nord:
return Self.palette([
0x3B4252, 0xBF616A, 0xA3BE8C, 0xEBCB8B,
0x81A1C1, 0xB48EAD, 0x88C0D0, 0xE5E9F0,
0x4C566A, 0xBF616A, 0xA3BE8C, 0xEBCB8B,
0x81A1C1, 0xB48EAD, 0x8FBCBB, 0xECEFF4
])
}
}
static func resolve(_ rawValue: String) -> TerminalTheme {
TerminalTheme(rawValue: rawValue) ?? .terminalApp
}
private static func palette(_ hexValues: [UInt32]) -> [Color] {
hexValues.map(terminalColor)
}
private static func terminalColor(_ hex: UInt32) -> Color {
Color(
red: UInt16(((hex >> 16) & 0xFF) * 257),
green: UInt16(((hex >> 8) & 0xFF) * 257),
blue: UInt16((hex & 0xFF) * 257)
)
}
private static func nsColor(_ hex: UInt32) -> NSColor {
NSColor(
deviceRed: CGFloat((hex >> 16) & 0xFF) / 255.0,
green: CGFloat((hex >> 8) & 0xFF) / 255.0,
blue: CGFloat(hex & 0xFF) / 255.0,
alpha: 1.0
)
}
}

View File

@@ -0,0 +1,174 @@
import SwiftUI
import Combine
@MainActor
protocol TerminalSessionFactoryType {
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession
}
struct LiveTerminalSessionFactory: TerminalSessionFactoryType {
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
TerminalSession(fontSize: fontSize, theme: theme, shellPath: shellPath)
}
}
@MainActor
final class WorkspaceController: ObservableObject {
let id: WorkspaceID
let createdAt: Date
@Published private(set) var name: String
@Published private(set) var hotkey: HotkeyBinding?
@Published private(set) var tabs: [TerminalSession] = []
@Published private(set) var activeTabIndex: Int = 0
private let sessionFactory: TerminalSessionFactoryType
private let settingsProvider: TerminalSessionConfigurationProviding
private var titleObservers: [UUID: AnyCancellable] = [:]
init(
summary: WorkspaceSummary,
sessionFactory: TerminalSessionFactoryType,
settingsProvider: TerminalSessionConfigurationProviding,
bootstrapDefaultTab: Bool = true
) {
self.id = summary.id
self.name = summary.name
self.createdAt = summary.createdAt
self.hotkey = summary.hotkey
self.sessionFactory = sessionFactory
self.settingsProvider = settingsProvider
if bootstrapDefaultTab {
newTab()
}
}
convenience init(summary: WorkspaceSummary) {
self.init(
summary: summary,
sessionFactory: LiveTerminalSessionFactory(),
settingsProvider: AppSettingsController.shared
)
}
var summary: WorkspaceSummary {
WorkspaceSummary(id: id, name: name, createdAt: createdAt, hotkey: hotkey)
}
var state: WorkspaceState {
WorkspaceState(
id: id,
name: name,
tabs: tabs.map { WorkspaceTabState(id: $0.id, title: $0.title) },
activeTabID: activeTab?.id
)
}
var activeTab: TerminalSession? {
guard tabs.indices.contains(activeTabIndex) else { return nil }
return tabs[activeTabIndex]
}
var activeTitle: String {
activeTab?.title ?? "shell"
}
func rename(to updatedName: String) {
let trimmed = updatedName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != name else { return }
name = trimmed
}
func updateHotkey(_ updatedHotkey: HotkeyBinding?) {
guard hotkey != updatedHotkey else { return }
hotkey = updatedHotkey
}
func newTab() {
let config = settingsProvider.terminalSessionConfiguration
let session = sessionFactory.makeSession(
fontSize: config.fontSize,
theme: config.theme,
shellPath: config.shellPath
)
titleObservers[session.id] = session.$title
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
tabs.append(session)
activeTabIndex = tabs.count - 1
}
func closeTab(at index: Int) {
guard tabs.indices.contains(index) else { return }
let session = tabs.remove(at: index)
titleObservers.removeValue(forKey: session.id)
session.terminate()
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 switchToTab(id: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == id }) 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
}
func detachTab(at index: Int) -> TerminalSession? {
guard tabs.indices.contains(index) else { return nil }
let session = tabs.remove(at: index)
titleObservers.removeValue(forKey: session.id)
if tabs.isEmpty {
newTab()
} else if activeTabIndex >= tabs.count {
activeTabIndex = tabs.count - 1
}
return session
}
func detachActiveTab() -> TerminalSession? {
detachTab(at: activeTabIndex)
}
func updateAllFontSizes(_ size: CGFloat) {
for tab in tabs {
tab.updateFontSize(size)
}
}
func updateAllThemes(_ theme: TerminalTheme) {
for tab in tabs {
tab.applyTheme(theme)
}
}
}

View File

@@ -0,0 +1,181 @@
import SwiftUI
@MainActor
final class WorkspaceRegistry: ObservableObject {
static let shared = WorkspaceRegistry(store: UserDefaultsWorkspaceStore())
@Published private(set) var workspaceSummaries: [WorkspaceSummary]
private let store: any WorkspaceStoreType
private var controllers: [WorkspaceID: WorkspaceController] = [:]
private let controllerFactory: @MainActor (WorkspaceSummary) -> WorkspaceController
init(
initialWorkspaces: [WorkspaceSummary]? = nil,
store: (any WorkspaceStoreType)? = nil,
controllerFactory: @escaping @MainActor (WorkspaceSummary) -> WorkspaceController = { summary in
WorkspaceController(summary: summary)
}
) {
let resolvedStore = store ?? UserDefaultsWorkspaceStore()
let resolvedWorkspaces = initialWorkspaces ?? resolvedStore.loadWorkspaceSummaries()
self.store = resolvedStore
self.controllerFactory = controllerFactory
self.workspaceSummaries = resolvedWorkspaces
for summary in resolvedWorkspaces {
controllers[summary.id] = controllerFactory(summary)
}
_ = ensureWorkspaceExists()
}
var defaultWorkspaceID: WorkspaceID {
ensureWorkspaceExists()
}
var defaultWorkspaceController: WorkspaceController {
let workspaceID = ensureWorkspaceExists()
guard let controller = controllers[workspaceID] else {
let summary = WorkspaceSummary(id: workspaceID, name: "Main")
let controller = controllerFactory(summary)
controllers[workspaceID] = controller
return controller
}
return controller
}
func allWorkspaceSummaries() -> [WorkspaceSummary] {
workspaceSummaries
}
func summary(for id: WorkspaceID) -> WorkspaceSummary? {
workspaceSummaries.first { $0.id == id }
}
func controller(for id: WorkspaceID) -> WorkspaceController? {
controllers[id]
}
func canDeleteWorkspace(id: WorkspaceID) -> Bool {
workspaceSummaries.count > 1 && workspaceSummaries.contains { $0.id == id }
}
func deletionFallbackWorkspaceID(
forDeleting id: WorkspaceID,
preferredFallback preferredFallbackID: WorkspaceID? = nil
) -> WorkspaceID? {
let candidates = workspaceSummaries.filter { $0.id != id }
if let preferredFallbackID,
candidates.contains(where: { $0.id == preferredFallbackID }) {
return preferredFallbackID
}
return candidates.first?.id
}
@discardableResult
func ensureWorkspaceExists() -> WorkspaceID {
if let existing = workspaceSummaries.first {
return existing.id
}
return createWorkspace(named: "Main")
}
@discardableResult
func createWorkspace(named name: String? = nil) -> WorkspaceID {
let workspaceName = resolvedWorkspaceName(from: name)
let summary = WorkspaceSummary(name: workspaceName)
workspaceSummaries.append(summary)
controllers[summary.id] = controllerFactory(summary)
persistWorkspaceSummaries()
return summary.id
}
func renameWorkspace(id: WorkspaceID, to name: String) {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { return }
workspaceSummaries[index].name = trimmed
controllers[id]?.rename(to: trimmed)
persistWorkspaceSummaries()
}
func updateWorkspaceHotkey(id: WorkspaceID, to hotkey: HotkeyBinding?) {
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { return }
guard workspaceSummaries[index].hotkey != hotkey else { return }
workspaceSummaries[index].hotkey = hotkey
controllers[id]?.updateHotkey(hotkey)
persistWorkspaceSummaries()
}
func nextWorkspaceID(after id: WorkspaceID) -> WorkspaceID? {
guard !workspaceSummaries.isEmpty else { return nil }
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else {
return workspaceSummaries.first?.id
}
let nextIndex = workspaceSummaries.index(after: index)
return workspaceSummaries[nextIndex == workspaceSummaries.endIndex ? workspaceSummaries.startIndex : nextIndex].id
}
func previousWorkspaceID(before id: WorkspaceID) -> WorkspaceID? {
guard !workspaceSummaries.isEmpty else { return nil }
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else {
return workspaceSummaries.last?.id
}
let previousIndex = index == workspaceSummaries.startIndex
? workspaceSummaries.index(before: workspaceSummaries.endIndex)
: workspaceSummaries.index(before: index)
return workspaceSummaries[previousIndex].id
}
@discardableResult
func deleteWorkspace(id: WorkspaceID) -> Bool {
guard canDeleteWorkspace(id: id) else { return false }
workspaceSummaries.removeAll { $0.id == id }
controllers.removeValue(forKey: id)
_ = ensureWorkspaceExists()
persistWorkspaceSummaries()
return true
}
func updateAllWorkspacesFontSizes(_ size: CGFloat) {
for controller in controllers.values {
controller.updateAllFontSizes(size)
}
}
func updateAllWorkspacesThemes(_ theme: TerminalTheme) {
for controller in controllers.values {
controller.updateAllThemes(theme)
}
}
private func resolvedWorkspaceName(from proposedName: String?) -> String {
let trimmed = proposedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty {
return trimmed
}
let existing = Set(workspaceSummaries.map(\.name))
if !existing.contains("Main") {
return "Main"
}
var index = 2
while existing.contains("Workspace \(index)") {
index += 1
}
return "Workspace \(index)"
}
private func persistWorkspaceSummaries() {
store.saveWorkspaceSummaries(workspaceSummaries)
}
}

View File

@@ -0,0 +1,59 @@
import Foundation
protocol WorkspaceStoreType {
func loadWorkspaceSummaries() -> [WorkspaceSummary]
func saveWorkspaceSummaries(_ summaries: [WorkspaceSummary])
}
protocol ScreenAssignmentStoreType {
func loadScreenAssignments() -> [ScreenID: WorkspaceID]
func saveScreenAssignments(_ assignments: [ScreenID: WorkspaceID])
}
struct UserDefaultsWorkspaceStore: WorkspaceStoreType {
private let defaults: UserDefaults
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func loadWorkspaceSummaries() -> [WorkspaceSummary] {
guard let data = defaults.data(forKey: NotchSettings.Keys.workspaceSummaries),
let summaries = try? decoder.decode([WorkspaceSummary].self, from: data) else {
return []
}
return summaries
}
func saveWorkspaceSummaries(_ summaries: [WorkspaceSummary]) {
guard let data = try? encoder.encode(summaries) else { return }
defaults.set(data, forKey: NotchSettings.Keys.workspaceSummaries)
}
}
struct UserDefaultsScreenAssignmentStore: ScreenAssignmentStoreType {
private let defaults: UserDefaults
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func loadScreenAssignments() -> [ScreenID: WorkspaceID] {
guard let data = defaults.data(forKey: NotchSettings.Keys.screenAssignments),
let assignments = try? decoder.decode([ScreenID: WorkspaceID].self, from: data) else {
return [:]
}
return assignments
}
func saveScreenAssignments(_ assignments: [ScreenID: WorkspaceID]) {
guard let data = try? encoder.encode(assignments) else { return }
defaults.set(data, forKey: NotchSettings.Keys.screenAssignments)
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
typealias WorkspaceID = UUID
struct WorkspaceSummary: Identifiable, Equatable, Codable {
var id: WorkspaceID
var name: String
var createdAt: Date
var hotkey: HotkeyBinding?
init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date(), hotkey: HotkeyBinding? = nil) {
self.id = id
self.name = name
self.createdAt = createdAt
self.hotkey = hotkey
}
}
struct WorkspaceTabState: Identifiable, Equatable {
var id: UUID
var title: String
}
struct WorkspaceState: Equatable {
var id: WorkspaceID
var name: String
var tabs: [WorkspaceTabState]
var activeTabID: UUID?
}

View File

@@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View 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>

View 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>CommandNotch</string>
<key>CFBundleExecutable</key>
<string>CommandNotch</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>CommandNotch</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.3</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 CommandNotch. All rights reserved.</string>
</dict>
</plist>

View File

@@ -0,0 +1,29 @@
import SwiftUI
struct AboutSettingsView: View {
private var versionLabel: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
}
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("CommandNotch")
.font(.largeTitle.bold())
Text("Version \(versionLabel)")
.foregroundStyle(.secondary)
Text("A drop-down terminal that lives in your notch.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Spacer()
}
.frame(maxWidth: .infinity)
.padding(.top, 40)
}
}

View File

@@ -0,0 +1,72 @@
import SwiftUI
struct AnimationSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
var body: some View {
Form {
Section("Open Animation") {
springControls(
response: settingsController.binding(\.animation.openSpringResponse),
damping: settingsController.binding(\.animation.openSpringDamping)
)
}
Section("Close Animation") {
springControls(
response: settingsController.binding(\.animation.closeSpringResponse),
damping: settingsController.binding(\.animation.closeSpringDamping)
)
}
Section("Hover Animation") {
springControls(
response: settingsController.binding(\.animation.hoverSpringResponse),
damping: settingsController.binding(\.animation.hoverSpringDamping)
)
}
Section("Resize Animation") {
durationControl(duration: settingsController.binding(\.animation.resizeAnimationDuration))
}
Section {
Button("Reset to Defaults") {
settingsController.update {
$0.animation = AppSettings.default.animation
}
}
}
}
.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)
}
}
@ViewBuilder
private func durationControl(duration: Binding<Double>) -> some View {
HStack {
Text("Duration")
Slider(value: duration, in: 0.05...1.5, step: 0.01)
Text(String(format: "%.2fs", duration.wrappedValue))
.monospacedDigit()
.frame(width: 56)
}
}
}

View File

@@ -0,0 +1,51 @@
import SwiftUI
struct AppearanceSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
var body: some View {
Form {
Section("Shadow") {
Toggle("Enable shadow", isOn: settingsController.binding(\.appearance.enableShadow))
if settingsController.settings.appearance.enableShadow {
HStack {
Text("Radius")
Slider(value: settingsController.binding(\.appearance.shadowRadius), in: 0...30, step: 1)
Text(String(format: "%.0f", settingsController.settings.appearance.shadowRadius))
.monospacedDigit()
.frame(width: 40)
}
HStack {
Text("Opacity")
Slider(value: settingsController.binding(\.appearance.shadowOpacity), in: 0...1, step: 0.05)
Text(String(format: "%.2f", settingsController.settings.appearance.shadowOpacity))
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Shape") {
Toggle("Scale corner radii when open", isOn: settingsController.binding(\.appearance.cornerRadiusScaling))
}
Section("Opacity & Blur") {
HStack {
Text("Notch opacity")
Slider(value: settingsController.binding(\.appearance.notchOpacity), in: 0...1, step: 0.05)
Text(String(format: "%.2f", settingsController.settings.appearance.notchOpacity))
.monospacedDigit()
.frame(width: 50)
}
HStack {
Text("Blur radius")
Slider(value: settingsController.binding(\.appearance.blurRadius), in: 0...20, step: 0.5)
Text(String(format: "%.1f", settingsController.settings.appearance.blurRadius))
.monospacedDigit()
.frame(width: 50)
}
}
}
.formStyle(.grouped)
}
}

View File

@@ -0,0 +1,107 @@
import AppKit
import SwiftUI
struct GeneralSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
private var maxOpenWidth: Double {
let currentWidth = settingsController.settings.display.openWidth
let screenWidth = NSScreen.screens.map { $0.frame.width - 40 }.max() ?? 1600
return max(currentWidth, Double(screenWidth.rounded()))
}
private var maxOpenHeight: Double {
let currentHeight = settingsController.settings.display.openHeight
let screenHeight = NSScreen.screens.map { $0.frame.height - 20 }.max() ?? 900
return max(currentHeight, Double(screenHeight.rounded()))
}
var body: some View {
Form {
Section("Display") {
Toggle("Show on all displays", isOn: settingsController.binding(\.display.showOnAllDisplays))
Toggle("Show menu bar icon", isOn: settingsController.binding(\.display.showMenuBarIcon))
Toggle("Launch at login", isOn: settingsController.binding(\.display.launchAtLogin))
.onChange(of: settingsController.settings.display.launchAtLogin) { _, newValue in
LaunchAtLoginHelper.setEnabled(newValue)
}
}
Section("Hover Behavior") {
Toggle("Open notch on hover", isOn: settingsController.binding(\.behavior.openNotchOnHover))
if settingsController.settings.behavior.openNotchOnHover {
HStack {
Text("Hover delay")
Slider(value: settingsController.binding(\.behavior.minimumHoverDuration), in: 0.0...2.0, step: 0.05)
Text(String(format: "%.2fs", settingsController.settings.behavior.minimumHoverDuration))
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Gestures") {
Toggle("Enable gestures", isOn: settingsController.binding(\.behavior.enableGestures))
if settingsController.settings.behavior.enableGestures {
HStack {
Text("Sensitivity")
Slider(value: settingsController.binding(\.behavior.gestureSensitivity), in: 0.1...1.0, step: 0.05)
Text(String(format: "%.2f", settingsController.settings.behavior.gestureSensitivity))
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Closed Notch Size") {
Picker("Notch screens", selection: settingsController.binding(\.display.notchHeightMode)) {
ForEach(NotchHeightMode.allCases) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
if settingsController.settings.display.notchHeightMode == NotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: settingsController.binding(\.display.notchHeight), in: 16...64, step: 1)
Text("\(Int(settingsController.settings.display.notchHeight))pt")
.monospacedDigit()
.frame(width: 50)
}
}
Picker("Non-notch screens", selection: settingsController.binding(\.display.nonNotchHeightMode)) {
ForEach(NonNotchHeightMode.allCases) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
if settingsController.settings.display.nonNotchHeightMode == NonNotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: settingsController.binding(\.display.nonNotchHeight), in: 16...64, step: 1)
Text("\(Int(settingsController.settings.display.nonNotchHeight))pt")
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Open Notch Size") {
HStack {
Text("Width")
Slider(value: settingsController.binding(\.display.openWidth), in: 320...maxOpenWidth, step: 10)
Text("\(Int(settingsController.settings.display.openWidth))pt")
.monospacedDigit()
.frame(width: 60)
}
HStack {
Text("Height")
Slider(value: settingsController.binding(\.display.openHeight), in: 140...maxOpenHeight, step: 10)
Text("\(Int(settingsController.settings.display.openHeight))pt")
.monospacedDigit()
.frame(width: 60)
}
}
}
.formStyle(.grouped)
}
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
struct HotkeySettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
var body: some View {
Form {
Section("Global") {
HotkeyRecorderView(label: "Toggle notch", binding: settingsController.binding(\.hotkeys.toggle))
}
Section("Terminal Tabs (active when notch is open)") {
HotkeyRecorderView(label: "New tab", binding: settingsController.binding(\.hotkeys.newTab))
HotkeyRecorderView(label: "Close tab", binding: settingsController.binding(\.hotkeys.closeTab))
HotkeyRecorderView(label: "Next tab", binding: settingsController.binding(\.hotkeys.nextTab))
HotkeyRecorderView(label: "Previous tab", binding: settingsController.binding(\.hotkeys.previousTab))
HotkeyRecorderView(label: "Detach tab", binding: settingsController.binding(\.hotkeys.detachTab))
}
Section("Workspaces (active when notch is open)") {
HotkeyRecorderView(label: "Next workspace", binding: settingsController.binding(\.hotkeys.nextWorkspace))
HotkeyRecorderView(label: "Previous workspace", binding: settingsController.binding(\.hotkeys.previousWorkspace))
}
Section {
Text("⌘19 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets. Per-workspace jump hotkeys are configured in Workspaces.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Button("Reset to Defaults") {
settingsController.update {
$0.hotkeys = AppSettings.default.hotkeys
}
}
}
}
.formStyle(.grouped)
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
@MainActor
extension AppSettingsController {
func binding<Value>(_ keyPath: WritableKeyPath<AppSettings, Value>) -> Binding<Value> {
Binding(
get: { self.settings[keyPath: keyPath] },
set: { newValue in
self.update { $0[keyPath: keyPath] = newValue }
}
)
}
}

View File

@@ -0,0 +1,87 @@
import SwiftUI
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)
.accessibilityIdentifier("settings.tab.\(tab.rawValue)")
}
.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 .workspaces:
WorkspacesSettingsView()
case .animation:
AnimationSettingsView()
case .terminal:
TerminalSettingsView()
case .hotkeys:
HotkeySettingsView()
case .about:
AboutSettingsView()
}
}
}
enum SettingsTab: String, CaseIterable, Identifiable {
case general, appearance, workspaces, animation, terminal, hotkeys, about
var id: String { rawValue }
var label: String {
switch self {
case .general:
"General"
case .appearance:
"Appearance"
case .workspaces:
"Workspaces"
case .animation:
"Animation"
case .terminal:
"Terminal"
case .hotkeys:
"Hotkeys"
case .about:
"About"
}
}
var icon: String {
switch self {
case .general:
"gearshape"
case .appearance:
"paintbrush"
case .workspaces:
"rectangle.3.group"
case .animation:
"bolt.fill"
case .terminal:
"terminal"
case .hotkeys:
"keyboard"
case .about:
"info.circle"
}
}
}

View File

@@ -0,0 +1,159 @@
import SwiftUI
struct TerminalSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
@State private var sizePresets: [TerminalSizePreset] = []
var body: some View {
Form {
Section("Font") {
HStack {
Text("Font size")
Slider(value: settingsController.binding(\.terminal.fontSize), in: 8...28, step: 1)
Text("\(Int(settingsController.settings.terminal.fontSize))pt")
.monospacedDigit()
.frame(width: 50)
}
}
Section("Colors") {
Picker("Theme", selection: settingsController.binding(\.terminal.themeRawValue)) {
ForEach(TerminalTheme.allCases) { terminalTheme in
Text(terminalTheme.label).tag(terminalTheme.rawValue)
}
}
Text(settingsController.settings.terminal.theme.detail)
.font(.caption)
.foregroundStyle(.secondary)
Text("Applies to normal terminal text and the ANSI palette used by tools like `ls`.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Shell") {
TextField("Shell path (empty = $SHELL)", text: settingsController.binding(\.terminal.shellPath))
.textFieldStyle(.roundedBorder)
Text("Leave empty to use your default shell ($SHELL or /bin/zsh).")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Size Presets") {
ForEach($sizePresets) { $preset in
TerminalSizePresetEditor(
preset: $preset,
currentOpenWidth: settingsController.settings.display.openWidth,
currentOpenHeight: settingsController.settings.display.openHeight,
onDelete: { deletePreset(id: preset.id) },
onApply: { applyPreset(preset) }
)
}
HStack {
Button("Add Preset") {
sizePresets.append(
TerminalSizePreset(
name: "Preset \(sizePresets.count + 1)",
width: settingsController.settings.display.openWidth,
height: settingsController.settings.display.openHeight,
hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets)
)
)
}
Button("Reset Presets") {
sizePresets = TerminalSizePresetStore.loadDefaults()
}
}
Text("Size preset hotkeys are active when the notch is open. Default presets use ⌘⇧1, ⌘⇧2, and ⌘⇧3.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onAppear {
synchronizePresetsFromSettings()
}
.onChange(of: settingsController.settings.terminal.sizePresetsJSON) { _, _ in
synchronizePresetsFromSettings()
}
.onChange(of: sizePresets) { _, newValue in
let encoded = TerminalSizePresetStore.encodePresets(newValue)
guard encoded != settingsController.settings.terminal.sizePresetsJSON else { return }
settingsController.update {
$0.terminal.sizePresetsJSON = encoded
}
}
}
private func deletePreset(id: UUID) {
sizePresets.removeAll { $0.id == id }
}
private func applyPreset(_ preset: TerminalSizePreset) {
settingsController.update {
$0.display.openWidth = preset.width
$0.display.openHeight = preset.height
}
ScreenManager.shared.applySizePreset(preset)
}
private func synchronizePresetsFromSettings() {
let decoded = TerminalSizePresetStore.decodePresets(
from: settingsController.settings.terminal.sizePresetsJSON
) ?? TerminalSizePresetStore.loadDefaults()
guard decoded != sizePresets else { return }
sizePresets = decoded
}
}
private struct TerminalSizePresetEditor: View {
@Binding var preset: TerminalSizePreset
let currentOpenWidth: Double
let currentOpenHeight: Double
let onDelete: () -> Void
let onApply: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("Preset name", text: $preset.name)
.textFieldStyle(.roundedBorder)
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
HStack {
Text("Width")
TextField("Width", value: $preset.width, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Text("Height")
TextField("Height", value: $preset.height, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Spacer()
Button("Use Current Size") {
preset.width = currentOpenWidth
preset.height = currentOpenHeight
}
Button("Apply", action: onApply)
}
OptionalHotkeyRecorderView(label: "Hotkey", binding: $preset.hotkey)
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,161 @@
import SwiftUI
struct WorkspaceSwitcherView: View {
@ObservedObject var screen: ScreenContext
let orchestrator: NotchOrchestrator
@ObservedObject private var screenRegistry = ScreenRegistry.shared
@ObservedObject private var workspaceRegistry = WorkspaceRegistry.shared
@State private var isRenameAlertPresented = false
@State private var isDeleteConfirmationPresented = false
@State private var renameDraft = ""
private var currentWorkspaceSummary: WorkspaceSummary {
workspaceRegistry.summary(for: screen.workspaceID)
?? workspaceRegistry.allWorkspaceSummaries().first
?? WorkspaceSummary(id: screen.workspaceID, name: "Workspace")
}
private var deletionFallbackSummary: WorkspaceSummary? {
guard let fallbackWorkspaceID = workspaceRegistry.deletionFallbackWorkspaceID(
forDeleting: screen.workspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return nil
}
return workspaceRegistry.summary(for: fallbackWorkspaceID)
}
private var assignedScreenCount: Int {
screenRegistry.assignedScreenCount(to: screen.workspaceID)
}
var body: some View {
Menu {
ForEach(workspaceRegistry.workspaceSummaries) { summary in
Button {
selectWorkspace(summary.id)
} label: {
if summary.id == screen.workspaceID {
Label(summary.name, systemImage: "checkmark")
} else {
Text(summary.name)
}
}
}
Divider()
Button("New Workspace") {
let workspaceID = workspaceRegistry.createWorkspace()
selectWorkspace(workspaceID)
}
Button("Rename Current Workspace") {
renameDraft = currentWorkspaceSummary.name
syncFocusLossSuppression(renamePresented: true, deletePresented: isDeleteConfirmationPresented)
isRenameAlertPresented = true
}
Button("Delete Current Workspace", role: .destructive) {
syncFocusLossSuppression(renamePresented: isRenameAlertPresented, deletePresented: true)
isDeleteConfirmationPresented = true
}
.disabled(!workspaceRegistry.canDeleteWorkspace(id: screen.workspaceID))
} label: {
switcherLabel
}
.menuStyle(.borderlessButton)
.accessibilityIdentifier("notch.workspace-switcher")
.accessibilityLabel("Workspace Switcher")
.accessibilityValue(currentWorkspaceSummary.name)
.fixedSize(horizontal: false, vertical: true)
.help("Switch workspace for this screen")
.alert("Rename Workspace", isPresented: $isRenameAlertPresented) {
TextField("Workspace name", text: $renameDraft)
Button("Cancel", role: .cancel) {}
Button("Save") {
workspaceRegistry.renameWorkspace(id: screen.workspaceID, to: renameDraft)
}
} message: {
Text("This only renames the shared workspace. Screens assigned to it keep following the new name.")
}
.confirmationDialog("Delete Workspace", isPresented: $isDeleteConfirmationPresented, titleVisibility: .visible) {
Button("Delete Workspace", role: .destructive) {
deleteCurrentWorkspace()
}
} message: {
Text(deleteMessage)
}
.onChange(of: isRenameAlertPresented) { _, isPresented in
syncFocusLossSuppression(renamePresented: isPresented, deletePresented: isDeleteConfirmationPresented)
}
.onChange(of: isDeleteConfirmationPresented) { _, isPresented in
syncFocusLossSuppression(renamePresented: isRenameAlertPresented, deletePresented: isPresented)
}
.onDisappear {
screen.setCloseOnFocusLossSuppressed(false)
}
}
private var deleteMessage: String {
if let fallback = deletionFallbackSummary {
return "This reassigns \(assignedScreenCount) screen\(assignedScreenCount == 1 ? "" : "s") to \(fallback.name) and closes this workspace."
}
return "At least one workspace must remain."
}
private var switcherLabel: some View {
HStack(spacing: 6) {
Image(systemName: "rectangle.3.group")
.font(.system(size: 11, weight: .medium))
Text(currentWorkspaceSummary.name)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.08))
)
.contentShape(Rectangle())
.accessibilityElement(children: .ignore)
.accessibilityLabel("Workspace Switcher")
.accessibilityValue(currentWorkspaceSummary.name)
.accessibilityIdentifier("notch.workspace-switcher")
}
private func selectWorkspace(_ workspaceID: WorkspaceID) {
screenRegistry.assignWorkspace(workspaceID, to: screen.id)
if screen.notchState == .open {
orchestrator.open(screenID: screen.id)
}
}
private func deleteCurrentWorkspace() {
guard let fallback = screenRegistry.deleteWorkspace(
screen.workspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return
}
screenRegistry.assignWorkspace(fallback, to: screen.id)
if screen.notchState == .open {
orchestrator.open(screenID: screen.id)
} else {
screen.requestTerminalFocus?()
}
}
private func syncFocusLossSuppression(renamePresented: Bool, deletePresented: Bool) {
screen.setCloseOnFocusLossSuppressed(renamePresented || deletePresented)
}
}

View File

@@ -0,0 +1,291 @@
import SwiftUI
struct WorkspacesSettingsView: View {
@ObservedObject private var workspaceRegistry = WorkspaceRegistry.shared
@ObservedObject private var screenRegistry = ScreenRegistry.shared
@State private var selectedWorkspaceID: WorkspaceID?
@State private var renameDraft = ""
@State private var isDeleteAlertPresented = false
private var effectiveSelectedWorkspaceID: WorkspaceID? {
selectedWorkspaceID ?? workspaceRegistry.workspaceSummaries.first?.id
}
private var selectedSummary: WorkspaceSummary? {
guard let effectiveSelectedWorkspaceID else { return nil }
return workspaceRegistry.summary(for: effectiveSelectedWorkspaceID)
}
private var selectedController: WorkspaceController? {
guard let effectiveSelectedWorkspaceID else { return nil }
return workspaceRegistry.controller(for: effectiveSelectedWorkspaceID)
}
private var selectedAssignedScreenIDs: [ScreenID] {
guard let effectiveSelectedWorkspaceID else { return [] }
return screenRegistry.assignedScreenIDs(to: effectiveSelectedWorkspaceID)
}
private var connectedScreenSummaries: [ConnectedScreenSummary] {
screenRegistry.connectedScreenSummaries()
}
private var activeConnectedScreenSummary: ConnectedScreenSummary? {
connectedScreenSummaries.first(where: \.isActive)
}
private var deletionFallbackSummary: WorkspaceSummary? {
guard let effectiveSelectedWorkspaceID,
let fallbackID = workspaceRegistry.deletionFallbackWorkspaceID(
forDeleting: effectiveSelectedWorkspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return nil
}
return workspaceRegistry.summary(for: fallbackID)
}
var body: some View {
HStack(spacing: 20) {
List(selection: $selectedWorkspaceID) {
ForEach(workspaceRegistry.workspaceSummaries) { summary in
VStack(alignment: .leading, spacing: 4) {
Text(summary.name)
.font(.headline)
Text(usageDescription(for: summary))
.font(.caption)
.foregroundStyle(.secondary)
}
.tag(summary.id)
.accessibilityIdentifier("settings.workspace.row.\(summary.id.uuidString)")
}
}
.accessibilityIdentifier("settings.workspaces.list")
.frame(minWidth: 220, idealWidth: 240, maxWidth: 260, maxHeight: .infinity)
if let summary = selectedSummary {
Form {
Section("Identity") {
TextField("Workspace name", text: $renameDraft)
.accessibilityIdentifier("settings.workspaces.name-field")
.onSubmit {
renameSelectedWorkspace()
}
OptionalHotkeyRecorderView(
label: "Jump Hotkey",
binding: workspaceHotkeyBinding(for: summary.id)
)
HStack {
Button("Save Name") {
renameSelectedWorkspace()
}
.accessibilityIdentifier("settings.workspaces.save-name")
Button("New Workspace") {
createWorkspace()
}
.accessibilityIdentifier("settings.workspaces.new")
}
Text("Workspace jump hotkeys are active when the notch is open and switch the current screen to this workspace.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Usage") {
LabeledContent("Assigned screens") {
Text("\(selectedAssignedScreenIDs.count)")
.accessibilityIdentifier("settings.workspaces.assigned-count")
}
LabeledContent("Open tabs") {
Text("\(selectedController?.tabs.count ?? 0)")
}
if selectedAssignedScreenIDs.isEmpty {
Text("No screens are currently assigned to this workspace.")
.foregroundStyle(.secondary)
} else {
ForEach(selectedAssignedScreenIDs, id: \.self) { screenID in
LabeledContent("Screen") {
Text(screenID)
.font(.caption.monospaced())
}
}
}
}
Section("Shared Workspace Rules") {
Text(sharedWorkspaceDescription(for: selectedAssignedScreenIDs.count))
.foregroundStyle(.secondary)
}
Section("Connected Screens") {
if let activeScreen = activeConnectedScreenSummary {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(activeScreen.displayName)
Text(activeScreen.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Button(activeScreen.assignedWorkspaceID == summary.id ? "Assigned Here" : "Assign Current Screen") {
screenRegistry.assignWorkspace(summary.id, to: activeScreen.id)
}
.accessibilityIdentifier("settings.workspaces.assign-current")
.disabled(activeScreen.assignedWorkspaceID == summary.id)
}
} else {
Text("No connected screens are currently available.")
.foregroundStyle(.secondary)
}
ForEach(connectedScreenSummaries.filter { !$0.isActive }) { screen in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(screen.displayName)
Text(screen.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Button(screen.assignedWorkspaceID == summary.id ? "Assigned Here" : "Assign Here") {
screenRegistry.assignWorkspace(summary.id, to: screen.id)
}
.accessibilityIdentifier("settings.workspaces.assign.\(screen.id)")
.disabled(screen.assignedWorkspaceID == summary.id)
}
}
}
Section("Danger Zone") {
Button("Delete Workspace", role: .destructive) {
isDeleteAlertPresented = true
}
.accessibilityIdentifier("settings.workspaces.delete")
.disabled(!workspaceRegistry.canDeleteWorkspace(id: summary.id))
if !workspaceRegistry.canDeleteWorkspace(id: summary.id) {
Text("At least one workspace must remain.")
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
} else {
ContentUnavailableView(
"No Workspaces",
systemImage: "rectangle.3.group",
description: Text("Create a workspace to start grouping tabs across screens.")
)
}
}
.onAppear {
selectInitialWorkspaceIfNeeded()
}
.onChange(of: workspaceRegistry.workspaceSummaries) { _, _ in
synchronizeSelectionWithRegistry()
}
.onChange(of: selectedWorkspaceID) { _, _ in
renameDraft = selectedSummary?.name ?? ""
}
.alert("Delete Workspace", isPresented: $isDeleteAlertPresented) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
deleteSelectedWorkspace()
}
} message: {
if let summary = selectedSummary, let fallback = deletionFallbackSummary {
Text(
"Deleting \(summary.name) reassigns its screens to \(fallback.name) and closes the workspace."
)
} else {
Text("At least one workspace must remain.")
}
}
}
private func usageDescription(for summary: WorkspaceSummary) -> String {
let screenCount = screenRegistry.assignedScreenCount(to: summary.id)
let tabCount = workspaceRegistry.controller(for: summary.id)?.tabs.count ?? 0
return "\(screenCount) screen\(screenCount == 1 ? "" : "s") · \(tabCount) tab\(tabCount == 1 ? "" : "s")"
}
private func sharedWorkspaceDescription(for screenCount: Int) -> String {
if screenCount > 1 {
return "This workspace is shared across \(screenCount) screens. Tab changes stay in sync across each assigned screen."
}
if screenCount == 1 {
return "This workspace is assigned to one screen. You can assign additional screens to share the same tabs."
}
return "Unassigned workspaces keep their tabs and can be attached to any screen later."
}
private func selectInitialWorkspaceIfNeeded() {
if selectedWorkspaceID == nil {
selectedWorkspaceID = workspaceRegistry.workspaceSummaries.first?.id
}
renameDraft = selectedSummary?.name ?? ""
}
private func synchronizeSelectionWithRegistry() {
guard let selectedWorkspaceID else {
selectInitialWorkspaceIfNeeded()
return
}
if workspaceRegistry.summary(for: selectedWorkspaceID) == nil {
self.selectedWorkspaceID = workspaceRegistry.workspaceSummaries.first?.id
}
renameDraft = selectedSummary?.name ?? ""
}
private func renameSelectedWorkspace() {
guard let effectiveSelectedWorkspaceID else { return }
workspaceRegistry.renameWorkspace(id: effectiveSelectedWorkspaceID, to: renameDraft)
renameDraft = selectedSummary?.name ?? renameDraft
}
private func createWorkspace() {
let workspaceID = workspaceRegistry.createWorkspace()
selectedWorkspaceID = workspaceID
renameDraft = workspaceRegistry.summary(for: workspaceID)?.name ?? ""
}
private func workspaceHotkeyBinding(for workspaceID: WorkspaceID) -> Binding<HotkeyBinding?> {
Binding(
get: {
workspaceRegistry.summary(for: workspaceID)?.hotkey
},
set: { newValue in
workspaceRegistry.updateWorkspaceHotkey(id: workspaceID, to: newValue)
}
)
}
private func deleteSelectedWorkspace() {
guard let effectiveSelectedWorkspaceID,
let fallbackWorkspaceID = screenRegistry.deleteWorkspace(
effectiveSelectedWorkspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return
}
self.selectedWorkspaceID = fallbackWorkspaceID
renameDraft = workspaceRegistry.summary(for: fallbackWorkspaceID)?.name ?? ""
}
}

View File

@@ -0,0 +1,42 @@
import XCTest
@testable import CommandNotch
@MainActor
final class AppSettingsControllerTests: XCTestCase {
func testTerminalSessionConfigurationIncludesShellPath() {
let store = InMemoryAppSettingsStore()
var settings = AppSettings.default
settings.terminal.shellPath = "/opt/homebrew/bin/fish"
store.storedSettings = settings
let controller = AppSettingsController(store: store)
XCTAssertEqual(controller.terminalSessionConfiguration.shellPath, "/opt/homebrew/bin/fish")
}
func testTerminalSizePresetsDecodeFromTypedSettings() {
let store = InMemoryAppSettingsStore()
let presets = [
TerminalSizePreset(name: "Wide", width: 960, height: 420, hotkey: .cmdShiftDigit(4))
]
var settings = AppSettings.default
settings.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets(presets)
store.storedSettings = settings
let controller = AppSettingsController(store: store)
XCTAssertEqual(controller.terminalSizePresets, presets)
}
}
private final class InMemoryAppSettingsStore: AppSettingsStoreType {
var storedSettings = AppSettings.default
func load() -> AppSettings {
storedSettings
}
func save(_ settings: AppSettings) {
storedSettings = settings
}
}

View File

@@ -0,0 +1,38 @@
import XCTest
@testable import CommandNotch
final class AppSettingsStoreTests: XCTestCase {
func testLoadReturnsDefaultValuesWhenStoreIsEmpty() {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
let store = UserDefaultsAppSettingsStore(defaults: defaults)
XCTAssertEqual(store.load(), .default)
}
func testSaveRoundTripsSettings() {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
let store = UserDefaultsAppSettingsStore(defaults: defaults)
var settings = AppSettings.default
settings.display.showMenuBarIcon = false
settings.display.showOnAllDisplays = false
settings.display.openWidth = 900
settings.behavior.minimumHoverDuration = 0.65
settings.appearance.blurRadius = 4.5
settings.terminal.fontSize = 16
settings.terminal.themeRawValue = TerminalTheme.dracula.rawValue
settings.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets([
TerminalSizePreset(name: "Wide", width: 960, height: 420, hotkey: .cmdShiftDigit(4))
])
settings.hotkeys.toggle = .cmdD
store.save(settings)
XCTAssertEqual(store.load(), settings)
}
}

View File

@@ -0,0 +1,248 @@
import XCTest
import Combine
@testable import CommandNotch
@MainActor
final class NotchOrchestratorTests: XCTestCase {
func testHoverOpenSchedulesOpenAfterDelay() {
let screenID = "screen-a"
let screen = makeScreenContext(screenID: screenID)
let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen])
let host = TestNotchPresentationHost()
let scheduler = TestScheduler()
let orchestrator = NotchOrchestrator(
screenRegistry: registry,
host: host,
settingsController: makeSettingsController(),
scheduler: scheduler
)
orchestrator.handleHoverChange(true, for: screenID)
XCTAssertEqual(screen.notchState, .closed)
scheduler.runScheduledActions()
XCTAssertEqual(screen.notchState, .open)
XCTAssertEqual(host.openedScreenIDs, [screenID])
}
func testHoverExitCancelsPendingOpen() {
let screenID = "screen-a"
let screen = makeScreenContext(screenID: screenID)
let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen])
let host = TestNotchPresentationHost()
let scheduler = TestScheduler()
let orchestrator = NotchOrchestrator(
screenRegistry: registry,
host: host,
settingsController: makeSettingsController(),
scheduler: scheduler
)
orchestrator.handleHoverChange(true, for: screenID)
orchestrator.handleHoverChange(false, for: screenID)
scheduler.runScheduledActions()
XCTAssertEqual(screen.notchState, .closed)
XCTAssertTrue(host.openedScreenIDs.isEmpty)
}
func testCloseWhileHoveringSuppressesReopenUntilHoverExit() {
let screenID = "screen-a"
let screen = makeScreenContext(screenID: screenID)
let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen])
let host = TestNotchPresentationHost()
let scheduler = TestScheduler()
let orchestrator = NotchOrchestrator(
screenRegistry: registry,
host: host,
settingsController: makeSettingsController(),
scheduler: scheduler
)
orchestrator.handleHoverChange(true, for: screenID)
scheduler.runScheduledActions()
XCTAssertEqual(screen.notchState, .open)
orchestrator.handleHoverChange(true, for: screenID)
orchestrator.close(screenID: screenID)
scheduler.runScheduledActions()
XCTAssertEqual(screen.notchState, .closed)
XCTAssertFalse(screen.isCloseTransitionActive)
XCTAssertTrue(screen.suppressHoverOpenUntilHoverExit)
XCTAssertEqual(host.closedScreenIDs, [screenID])
scheduler.runScheduledActions()
XCTAssertEqual(screen.notchState, .closed)
XCTAssertEqual(host.openedScreenIDs, [screenID])
orchestrator.handleHoverChange(false, for: screenID)
XCTAssertFalse(screen.suppressHoverOpenUntilHoverExit)
}
func testOpeningSharedWorkspaceOnAnotherScreenClosesPreviousPresenter() {
let workspaceID = UUID()
let firstScreen = makeScreenContext(screenID: "screen-a", workspaceID: workspaceID)
let secondScreen = makeScreenContext(screenID: "screen-b", workspaceID: workspaceID)
let registry = TestScreenRegistry(activeScreenID: "screen-b", screens: [firstScreen, secondScreen])
let host = TestNotchPresentationHost()
let orchestrator = NotchOrchestrator(
screenRegistry: registry,
host: host,
settingsController: makeSettingsController(),
scheduler: TestScheduler()
)
orchestrator.open(screenID: "screen-a")
orchestrator.open(screenID: "screen-b")
XCTAssertEqual(firstScreen.notchState, .closed)
XCTAssertEqual(secondScreen.notchState, .open)
XCTAssertEqual(host.closedScreenIDs, ["screen-a"])
XCTAssertEqual(registry.presentingScreenID(for: workspaceID), "screen-b")
}
func testOpeningDifferentWorkspaceDoesNotCloseOtherOpenScreen() {
let firstScreen = makeScreenContext(screenID: "screen-a", workspaceID: UUID())
let secondScreen = makeScreenContext(screenID: "screen-b", workspaceID: UUID())
let registry = TestScreenRegistry(activeScreenID: "screen-b", screens: [firstScreen, secondScreen])
let host = TestNotchPresentationHost()
let orchestrator = NotchOrchestrator(
screenRegistry: registry,
host: host,
settingsController: makeSettingsController(),
scheduler: TestScheduler()
)
orchestrator.open(screenID: "screen-a")
orchestrator.open(screenID: "screen-b")
XCTAssertEqual(firstScreen.notchState, .open)
XCTAssertEqual(secondScreen.notchState, .open)
XCTAssertTrue(host.closedScreenIDs.isEmpty)
XCTAssertEqual(registry.presentingScreenID(for: firstScreen.workspaceID), "screen-a")
XCTAssertEqual(registry.presentingScreenID(for: secondScreen.workspaceID), "screen-b")
}
private func makeScreenContext(screenID: ScreenID, workspaceID: WorkspaceID = UUID()) -> ScreenContext {
ScreenContext(
id: screenID,
workspaceID: workspaceID,
settingsController: makeSettingsController(),
screenProvider: { _ in nil }
)
}
private func makeSettingsController() -> AppSettingsController {
let store = TestOrchestratorSettingsStore()
var settings = AppSettings.default
settings.behavior.openNotchOnHover = true
settings.behavior.minimumHoverDuration = 0.3
store.storedSettings = settings
return AppSettingsController(store: store)
}
}
@MainActor
private final class TestScreenRegistry: ScreenRegistryType {
private let activeID: ScreenID
private var screensByID: [ScreenID: ScreenContext]
private var workspacePresenters: [WorkspaceID: ScreenID] = [:]
init(activeScreenID: ScreenID, screens: [ScreenContext]) {
self.activeID = activeScreenID
self.screensByID = Dictionary(uniqueKeysWithValues: screens.map { ($0.id, $0) })
}
func allScreens() -> [ScreenContext] {
Array(screensByID.values)
}
func screenContext(for id: ScreenID) -> ScreenContext? {
screensByID[id]
}
func activeScreenID() -> ScreenID? {
activeID
}
func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID? {
workspacePresenters[workspaceID]
}
func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID? {
guard let workspaceID = screensByID[screenID]?.workspaceID else { return nil }
let previous = workspacePresenters[workspaceID]
workspacePresenters[workspaceID] = screenID
return previous == screenID ? nil : previous
}
func releaseWorkspacePresentation(for screenID: ScreenID) {
workspacePresenters = workspacePresenters.filter { $0.value != screenID }
}
}
@MainActor
private final class TestNotchPresentationHost: NotchPresentationHost {
var openedScreenIDs: [ScreenID] = []
var closedScreenIDs: [ScreenID] = []
func canPresentNotch(for screenID: ScreenID) -> Bool {
true
}
func performOpenPresentation(for screenID: ScreenID) {
openedScreenIDs.append(screenID)
}
func performClosePresentation(for screenID: ScreenID) {
closedScreenIDs.append(screenID)
}
}
private final class TestScheduler: SchedulerType {
private final class ScheduledAction {
let action: @MainActor () -> Void
var isCancelled = false
init(action: @escaping @MainActor () -> Void) {
self.action = action
}
}
private var scheduledActions: [ScheduledAction] = []
@MainActor
func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable {
let scheduledAction = ScheduledAction(action: action)
scheduledActions.append(scheduledAction)
return AnyCancellable {
scheduledAction.isCancelled = true
}
}
@MainActor
func runScheduledActions() {
let actions = scheduledActions
scheduledActions.removeAll()
for scheduledAction in actions where !scheduledAction.isCancelled {
scheduledAction.action()
}
}
}
private final class TestOrchestratorSettingsStore: AppSettingsStoreType {
var storedSettings = AppSettings.default
func load() -> AppSettings {
storedSettings
}
func save(_ settings: AppSettings) {
storedSettings = settings
}
}

View File

@@ -0,0 +1,67 @@
import XCTest
@testable import CommandNotch
@MainActor
final class ScreenContextTests: XCTestCase {
func testInteractiveResizeDefersPersistingSettingsUntilResizeEnds() {
let store = ScreenContextTestSettingsStore()
var settings = AppSettings.default
settings.display.openWidth = 640
settings.display.openHeight = 350
store.storedSettings = settings
let controller = AppSettingsController(store: store)
let screen = ScreenContext(
id: "screen-a",
workspaceID: UUID(),
settingsController: controller,
screenProvider: { _ in nil }
)
screen.open()
screen.beginInteractiveResize()
screen.resizeOpenNotch(to: CGSize(width: 800, height: 420))
XCTAssertEqual(screen.notchSize.width, 800)
XCTAssertEqual(screen.notchSize.height, 420)
XCTAssertEqual(controller.settings.display.openWidth, 640)
XCTAssertEqual(controller.settings.display.openHeight, 350)
screen.endInteractiveResize()
XCTAssertEqual(controller.settings.display.openWidth, 800)
XCTAssertEqual(controller.settings.display.openHeight, 420)
XCTAssertEqual(store.storedSettings.display.openWidth, 800)
XCTAssertEqual(store.storedSettings.display.openHeight, 420)
}
func testFocusLossAutoCloseSuppressionCanBeToggled() {
let controller = AppSettingsController(store: ScreenContextTestSettingsStore())
let screen = ScreenContext(
id: "screen-a",
workspaceID: UUID(),
settingsController: controller,
screenProvider: { _ in nil }
)
XCTAssertFalse(screen.suppressCloseOnFocusLoss)
screen.setCloseOnFocusLossSuppressed(true)
XCTAssertTrue(screen.suppressCloseOnFocusLoss)
screen.setCloseOnFocusLossSuppressed(false)
XCTAssertFalse(screen.suppressCloseOnFocusLoss)
}
}
private final class ScreenContextTestSettingsStore: AppSettingsStoreType {
var storedSettings = AppSettings.default
func load() -> AppSettings {
storedSettings
}
func save(_ settings: AppSettings) {
storedSettings = settings
}
}

View File

@@ -0,0 +1,319 @@
import XCTest
@testable import CommandNotch
@MainActor
final class ScreenRegistryTests: XCTestCase {
func testRefreshCreatesContextsForConnectedScreensUsingDefaultWorkspace() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { ["screen-a", "screen-b"] },
activeScreenIDProvider: { "screen-b" },
screenLookup: { _ in nil }
)
XCTAssertEqual(registry.allScreens().map(\.id), ["screen-a", "screen-b"])
XCTAssertEqual(
registry.allScreens().map(\.workspaceID),
[workspaceRegistry.defaultWorkspaceID, workspaceRegistry.defaultWorkspaceID]
)
XCTAssertEqual(registry.activeScreenID(), "screen-b")
}
func testAssignWorkspaceUpdatesContextAndSurvivesReconnect() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
var connectedScreenIDs = ["screen-a"]
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { connectedScreenIDs },
activeScreenIDProvider: { connectedScreenIDs.first },
screenLookup: { _ in nil }
)
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, docsWorkspaceID)
connectedScreenIDs = []
registry.refreshConnectedScreens()
XCTAssertNil(registry.screenContext(for: "screen-a"))
connectedScreenIDs = ["screen-a"]
registry.refreshConnectedScreens()
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, docsWorkspaceID)
}
func testDeletedWorkspaceAssignmentFallsBackToDefaultWorkspace() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let assignmentStore = InMemoryScreenAssignmentStore()
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
assignmentStore: assignmentStore,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
registry.assignWorkspace(reviewWorkspaceID, to: "screen-a")
workspaceRegistry.deleteWorkspace(id: reviewWorkspaceID)
registry.refreshConnectedScreens()
XCTAssertEqual(
registry.screenContext(for: "screen-a")?.workspaceID,
workspaceRegistry.defaultWorkspaceID
)
XCTAssertEqual(
assignmentStore.savedAssignments["screen-a"],
workspaceRegistry.defaultWorkspaceID
)
}
func testRegistryLoadsPersistedAssignmentsFromStore() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let assignmentStore = InMemoryScreenAssignmentStore()
assignmentStore.savedAssignments = ["screen-a": docsWorkspaceID]
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
assignmentStore: assignmentStore,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, docsWorkspaceID)
}
func testAssignWorkspacePersistsAssignments() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let assignmentStore = InMemoryScreenAssignmentStore()
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
assignmentStore: assignmentStore,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
registry.assignWorkspace(reviewWorkspaceID, to: "screen-a")
XCTAssertEqual(assignmentStore.savedAssignments["screen-a"], reviewWorkspaceID)
}
func testWorkspaceControllerTracksAssignedWorkspace() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
XCTAssertEqual(registry.workspaceController(for: "screen-a").id, workspaceRegistry.defaultWorkspaceID)
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
XCTAssertEqual(registry.workspaceController(for: "screen-a").id, docsWorkspaceID)
}
func testDeleteWorkspaceReassignsConnectedAndPersistedScreensToFallback() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
let assignmentStore = InMemoryScreenAssignmentStore()
assignmentStore.savedAssignments = ["screen-a": docsWorkspaceID, "screen-b": docsWorkspaceID]
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
assignmentStore: assignmentStore,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
let fallbackWorkspaceID = registry.deleteWorkspace(
docsWorkspaceID,
preferredFallback: reviewWorkspaceID
)
XCTAssertEqual(fallbackWorkspaceID, reviewWorkspaceID)
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, reviewWorkspaceID)
XCTAssertEqual(assignmentStore.savedAssignments["screen-a"], reviewWorkspaceID)
XCTAssertEqual(assignmentStore.savedAssignments["screen-b"], reviewWorkspaceID)
}
func testAssignedScreenCountIncludesDisconnectedAssignments() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let assignmentStore = InMemoryScreenAssignmentStore()
assignmentStore.savedAssignments = ["screen-a": docsWorkspaceID, "screen-b": docsWorkspaceID]
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
assignmentStore: assignmentStore,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
XCTAssertEqual(registry.assignedScreenCount(to: docsWorkspaceID), 2)
XCTAssertEqual(registry.assignedScreenIDs(to: docsWorkspaceID), ["screen-a", "screen-b"])
}
func testClaimWorkspacePresentationTracksPresenterPerWorkspace() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { ["screen-a", "screen-b"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
registry.assignWorkspace(docsWorkspaceID, to: "screen-b")
XCTAssertNil(registry.claimWorkspacePresentation(for: "screen-a"))
XCTAssertEqual(registry.presentingScreenID(for: docsWorkspaceID), "screen-a")
XCTAssertEqual(registry.claimWorkspacePresentation(for: "screen-b"), "screen-a")
XCTAssertEqual(registry.presentingScreenID(for: docsWorkspaceID), "screen-b")
registry.releaseWorkspacePresentation(for: "screen-b")
XCTAssertNil(registry.presentingScreenID(for: docsWorkspaceID))
}
func testAssignWorkspaceReleasesPreviousPresentationOwnership() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
_ = registry.claimWorkspacePresentation(for: "screen-a")
XCTAssertEqual(registry.presentingScreenID(for: docsWorkspaceID), "screen-a")
registry.assignWorkspace(reviewWorkspaceID, to: "screen-a")
XCTAssertNil(registry.presentingScreenID(for: docsWorkspaceID))
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, reviewWorkspaceID)
}
func testConnectedScreenSummariesReflectActiveScreenAndAssignments() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { ["screen-a", "screen-b"] },
activeScreenIDProvider: { "screen-b" },
screenLookup: { _ in nil }
)
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
XCTAssertEqual(
registry.connectedScreenSummaries(),
[
ConnectedScreenSummary(
id: "screen-a",
displayName: "Screen 1",
isActive: false,
assignedWorkspaceID: docsWorkspaceID
),
ConnectedScreenSummary(
id: "screen-b",
displayName: "Screen 2",
isActive: true,
assignedWorkspaceID: workspaceRegistry.defaultWorkspaceID
)
]
)
}
private func makeWorkspaceRegistry() -> WorkspaceRegistry {
let settingsProvider = ScreenRegistryTestSettingsProvider()
let sessionFactory = ScreenRegistryUnusedTerminalSessionFactory()
return WorkspaceRegistry(
initialWorkspaces: [],
controllerFactory: { summary in
WorkspaceController(
summary: summary,
sessionFactory: sessionFactory,
settingsProvider: settingsProvider,
bootstrapDefaultTab: false
)
}
)
}
private func makeSettingsController() -> AppSettingsController {
AppSettingsController(store: TestAppSettingsStore())
}
}
private final class InMemoryScreenAssignmentStore: ScreenAssignmentStoreType {
var savedAssignments: [ScreenID: WorkspaceID] = [:]
func loadScreenAssignments() -> [ScreenID: WorkspaceID] {
savedAssignments
}
func saveScreenAssignments(_ assignments: [ScreenID: WorkspaceID]) {
savedAssignments = assignments
}
}
private final class TestAppSettingsStore: AppSettingsStoreType {
private var storedSettings = AppSettings.default
func load() -> AppSettings {
storedSettings
}
func save(_ settings: AppSettings) {
storedSettings = settings
}
}
private final class ScreenRegistryTestSettingsProvider: TerminalSessionConfigurationProviding {
let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "")
let hotkeySettings = AppSettings.default.hotkeys
let terminalSizePresets = TerminalSizePresetStore.loadDefaults()
}
private struct ScreenRegistryUnusedTerminalSessionFactory: TerminalSessionFactoryType {
@MainActor
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
fatalError("ScreenRegistryTests should not create live terminal sessions.")
}
}

View File

@@ -0,0 +1,57 @@
import AppKit
import Carbon.HIToolbox
import XCTest
import SwiftTerm
@testable import CommandNotch
final class TerminalCommandArrowBehaviorTests: XCTestCase {
func testCommandLeftUsesHomeSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_LeftArrow),
applicationCursor: false
)
XCTAssertEqual(sequence, EscapeSequences.moveHomeNormal)
}
func testCommandRightUsesApplicationEndSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_RightArrow),
applicationCursor: true
)
XCTAssertEqual(sequence, EscapeSequences.moveEndApp)
}
func testOptionLeftKeepsSwiftTermWordNavigationPath() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.option],
keyCode: UInt16(kVK_LeftArrow),
applicationCursor: false
)
XCTAssertNil(sequence)
}
func testCommandDeleteUsesLineKillSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_Delete),
applicationCursor: false
)
XCTAssertEqual(sequence, [0x15])
}
func testCommandLUsesClearScreenSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_ANSI_L),
applicationCursor: false
)
XCTAssertEqual(sequence, [0x0c])
}
}

View File

@@ -0,0 +1,32 @@
import XCTest
@testable import CommandNotch
final class WindowFrameCalculatorTests: XCTestCase {
func testClosedStateCentersWindowOnScreen() {
let frame = WindowFrameCalculator.targetFrame(
screenFrame: CGRect(x: 100, y: 50, width: 1600, height: 900),
currentWindowFrame: CGRect(x: 300, y: 0, width: 0, height: 0),
notchState: .closed,
contentSize: CGSize(width: 800, height: 300),
centerHorizontally: false
)
XCTAssertEqual(frame.origin.x, 480, accuracy: 0.001)
XCTAssertEqual(frame.origin.y, 630, accuracy: 0.001)
XCTAssertEqual(frame.size.width, 840, accuracy: 0.001)
XCTAssertEqual(frame.size.height, 320, accuracy: 0.001)
}
func testOpenStateClampsDraggedFrameWithinScreenBounds() {
let frame = WindowFrameCalculator.targetFrame(
screenFrame: CGRect(x: 0, y: 0, width: 1440, height: 900),
currentWindowFrame: CGRect(x: 1200, y: 0, width: 0, height: 0),
notchState: .open,
contentSize: CGSize(width: 900, height: 320),
centerHorizontally: false
)
XCTAssertEqual(frame.origin.x, 500, accuracy: 0.001)
XCTAssertEqual(frame.origin.y, 560, accuracy: 0.001)
}
}

View File

@@ -0,0 +1,146 @@
import XCTest
@testable import CommandNotch
@MainActor
final class WorkspaceRegistryTests: XCTestCase {
func testRegistryCreatesDefaultWorkspaceWhenEmpty() {
let registry = makeRegistry()
XCTAssertEqual(registry.allWorkspaceSummaries().count, 1)
XCTAssertEqual(registry.allWorkspaceSummaries().first?.name, "Main")
XCTAssertNotNil(registry.controller(for: registry.defaultWorkspaceID))
}
func testCreateRenameAndDeleteWorkspaceUpdatesSummaries() {
let registry = makeRegistry()
let createdID = registry.createWorkspace(named: "Docs")
XCTAssertEqual(registry.allWorkspaceSummaries().map(\.name), ["Main", "Docs"])
registry.renameWorkspace(id: createdID, to: "Review")
XCTAssertEqual(registry.allWorkspaceSummaries().map(\.name), ["Main", "Review"])
registry.deleteWorkspace(id: createdID)
XCTAssertEqual(registry.allWorkspaceSummaries().map(\.name), ["Main"])
}
func testDeletingLastWorkspaceIsIgnored() {
let registry = makeRegistry()
let onlyWorkspaceID = registry.defaultWorkspaceID
registry.deleteWorkspace(id: onlyWorkspaceID)
XCTAssertEqual(registry.allWorkspaceSummaries().count, 1)
XCTAssertEqual(registry.allWorkspaceSummaries().first?.id, onlyWorkspaceID)
}
func testDeletionFallbackPrefersRequestedWorkspaceWhenAvailable() {
let registry = makeRegistry()
let docsID = registry.createWorkspace(named: "Docs")
let reviewID = registry.createWorkspace(named: "Review")
let fallback = registry.deletionFallbackWorkspaceID(
forDeleting: docsID,
preferredFallback: reviewID
)
XCTAssertEqual(fallback, reviewID)
}
func testRegistryLoadsPersistedWorkspacesFromStore() {
let store = InMemoryWorkspaceStore()
let docsID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!
store.savedSummaries = [WorkspaceSummary(id: docsID, name: "Docs")]
let registry = makeRegistry(initialWorkspaces: nil, store: store)
XCTAssertEqual(registry.allWorkspaceSummaries().count, 1)
XCTAssertEqual(registry.allWorkspaceSummaries().first?.id, docsID)
XCTAssertEqual(registry.allWorkspaceSummaries().first?.name, "Docs")
XCTAssertEqual(registry.defaultWorkspaceID, docsID)
}
func testRegistryPersistsWorkspaceSummaryChanges() {
let store = InMemoryWorkspaceStore()
let registry = makeRegistry(store: store)
let createdID = registry.createWorkspace(named: "Docs")
XCTAssertEqual(store.savedSummaries.map(\.name), ["Main", "Docs"])
registry.renameWorkspace(id: createdID, to: "Review")
XCTAssertEqual(store.savedSummaries.map(\.name), ["Main", "Review"])
registry.deleteWorkspace(id: createdID)
XCTAssertEqual(store.savedSummaries.map(\.name), ["Main"])
}
func testUpdateWorkspaceHotkeyPersistsAndUpdatesSummary() {
let store = InMemoryWorkspaceStore()
let registry = makeRegistry(store: store)
let docsID = registry.createWorkspace(named: "Docs")
let hotkey = HotkeyBinding.cmdShiftDigit(4)
registry.updateWorkspaceHotkey(id: docsID, to: hotkey)
XCTAssertEqual(registry.summary(for: docsID)?.hotkey, hotkey)
XCTAssertEqual(store.savedSummaries.last?.hotkey, hotkey)
}
func testNextAndPreviousWorkspaceWrapAroundRegistryOrder() {
let registry = makeRegistry()
let mainID = registry.defaultWorkspaceID
let docsID = registry.createWorkspace(named: "Docs")
let reviewID = registry.createWorkspace(named: "Review")
XCTAssertEqual(registry.nextWorkspaceID(after: mainID), docsID)
XCTAssertEqual(registry.nextWorkspaceID(after: reviewID), mainID)
XCTAssertEqual(registry.previousWorkspaceID(before: mainID), reviewID)
XCTAssertEqual(registry.previousWorkspaceID(before: docsID), mainID)
}
private func makeRegistry(
initialWorkspaces: [WorkspaceSummary]? = [],
store: (any WorkspaceStoreType)? = nil
) -> WorkspaceRegistry {
let settingsProvider = TestSettingsProvider()
let sessionFactory = UnusedTerminalSessionFactory()
return WorkspaceRegistry(
initialWorkspaces: initialWorkspaces,
store: store,
controllerFactory: { summary in
WorkspaceController(
summary: summary,
sessionFactory: sessionFactory,
settingsProvider: settingsProvider,
bootstrapDefaultTab: false
)
}
)
}
}
private final class InMemoryWorkspaceStore: WorkspaceStoreType {
var savedSummaries: [WorkspaceSummary] = []
func loadWorkspaceSummaries() -> [WorkspaceSummary] {
savedSummaries
}
func saveWorkspaceSummaries(_ summaries: [WorkspaceSummary]) {
savedSummaries = summaries
}
}
private final class TestSettingsProvider: TerminalSessionConfigurationProviding {
let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "")
let hotkeySettings = AppSettings.default.hotkeys
let terminalSizePresets = TerminalSizePresetStore.loadDefaults()
}
private struct UnusedTerminalSessionFactory: TerminalSessionFactoryType {
@MainActor
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
fatalError("WorkspaceRegistryTests should not create live terminal sessions.")
}
}

View File

@@ -0,0 +1,40 @@
import XCTest
@testable import CommandNotch
final class WorkspaceStoreTests: XCTestCase {
func testWorkspaceStoreRoundTripsSummaries() {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
let store = UserDefaultsWorkspaceStore(defaults: defaults)
let summaries = [
WorkspaceSummary(
id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
name: "Main",
hotkey: HotkeyBinding.cmdShiftDigit(4)
),
WorkspaceSummary(id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, name: "Docs")
]
store.saveWorkspaceSummaries(summaries)
XCTAssertEqual(store.loadWorkspaceSummaries(), summaries)
}
func testScreenAssignmentStoreRoundTripsAssignments() {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
let store = UserDefaultsScreenAssignmentStore(defaults: defaults)
let assignments: [ScreenID: WorkspaceID] = [
"screen-a": UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
"screen-b": UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
]
store.saveScreenAssignments(assignments)
XCTAssertEqual(store.loadScreenAssignments(), assignments)
}
}

View File

@@ -0,0 +1,103 @@
import XCTest
final class CommandNotchUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testSettingsWorkspaceRenameFlow() {
let app = launchIntoSettings()
let newWorkspaceButton = app.buttons["settings.workspaces.new"]
XCTAssertTrue(newWorkspaceButton.waitForExistence(timeout: 5))
newWorkspaceButton.tap()
let nameField = app.textFields["settings.workspaces.name-field"]
XCTAssertTrue(nameField.waitForExistence(timeout: 5))
nameField.tap()
nameField.typeKey("a", modifierFlags: .command)
nameField.typeText(XCUIKeyboardKey.delete.rawValue)
nameField.typeText("Docs")
let saveButton = app.buttons["settings.workspaces.save-name"]
XCTAssertTrue(saveButton.exists)
saveButton.tap()
XCTAssertTrue(app.staticTexts["Docs"].waitForExistence(timeout: 5))
}
func testSettingsWorkspaceDeleteFallsBackToMain() {
let app = launchIntoSettings()
let newWorkspaceButton = app.buttons["settings.workspaces.new"]
XCTAssertTrue(newWorkspaceButton.waitForExistence(timeout: 5))
newWorkspaceButton.tap()
let nameField = app.textFields["settings.workspaces.name-field"]
XCTAssertTrue(nameField.waitForExistence(timeout: 5))
nameField.tap()
nameField.typeKey("a", modifierFlags: .command)
nameField.typeText(XCUIKeyboardKey.delete.rawValue)
nameField.typeText("Scratch")
let saveButton = app.buttons["settings.workspaces.save-name"]
XCTAssertTrue(saveButton.exists)
saveButton.tap()
XCTAssertTrue(app.staticTexts["Scratch"].waitForExistence(timeout: 5))
let assignCurrentButton = app.buttons["settings.workspaces.assign-current"]
XCTAssertTrue(assignCurrentButton.waitForExistence(timeout: 5))
assignCurrentButton.tap()
let deleteButton = app.buttons["settings.workspaces.delete"]
XCTAssertTrue(deleteButton.waitForExistence(timeout: 5))
deleteButton.tap()
let confirmDeleteButton = app.sheets.buttons["Delete"]
XCTAssertTrue(confirmDeleteButton.waitForExistence(timeout: 5))
confirmDeleteButton.tap()
let mainValuePredicate = NSPredicate(format: "value == %@", "Main")
expectation(for: mainValuePredicate, evaluatedWith: nameField)
waitForExpectations(timeout: 5)
}
func testOpenNotchLaunchShowsInteractiveControls() {
let app = XCUIApplication()
app.launchArguments = [
"--uitest-regular-activation",
"--uitest-open-notch"
]
app.launch()
let notch = app.descendants(matching: .any)["notch.container"]
XCTAssertTrue(notch.waitForExistence(timeout: 5))
let newTabButton = app.buttons["New Tab"]
XCTAssertTrue(newTabButton.waitForExistence(timeout: 5))
let settingsButton = app.buttons["Settings"]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 5))
settingsButton.tap()
XCTAssertTrue(app.windows["CommandNotch Settings"].waitForExistence(timeout: 5))
}
@discardableResult
private func launchIntoSettings() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = [
"--uitest-regular-activation",
"--uitest-show-settings"
]
app.launch()
let settingsWindow = app.windows["CommandNotch Settings"]
XCTAssertTrue(settingsWindow.waitForExistence(timeout: 5))
let workspacesTab = app.descendants(matching: .any)["settings.tab.workspaces"]
XCTAssertTrue(workspacesTab.waitForExistence(timeout: 5))
workspacesTab.tap()
return app
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

115
CommandNotch/project.yml Normal file
View File

@@ -0,0 +1,115 @@
name: CommandNotch
options:
bundleIdPrefix: com.commandnotch
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"
schemes:
CommandNotch:
build:
targets:
CommandNotch: all
test:
config: Debug
targets:
- CommandNotchTests
- CommandNotchUITests
run:
config: Debug
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
management:
shared: true
Release-CommandNotch:
build:
targets:
CommandNotch: all
test:
config: Debug
targets:
- CommandNotchTests
- CommandNotchUITests
run:
config: Release
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
management:
shared: true
targets:
CommandNotch:
type: application
platform: macOS
sources:
- path: CommandNotch
excludes:
- Resources/Info.plist
dependencies:
- package: SwiftTerm
info:
path: CommandNotch/Resources/Info.plist
properties:
CFBundleName: CommandNotch
CFBundleDisplayName: CommandNotch
CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)"
CFBundleVersion: "1"
CFBundleShortVersionString: "0.0.3"
CFBundlePackageType: APPL
CFBundleExecutable: CommandNotch
LSMinimumSystemVersion: "14.0"
LSUIElement: true
NSHumanReadableCopyright: "Copyright © 2026 CommandNotch. All rights reserved."
entitlements:
path: CommandNotch/Resources/CommandNotch.entitlements
settings:
base:
CODE_SIGN_ENTITLEMENTS: CommandNotch/Resources/CommandNotch.entitlements
INFOPLIST_FILE: CommandNotch/Resources/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.commandnotch.app
PRODUCT_NAME: CommandNotch
COMBINE_HIDPI_IMAGES: true
CommandNotchTests:
type: bundle.unit-test
platform: macOS
sources:
- path: CommandNotchTests
dependencies:
- target: CommandNotch
settings:
base:
GENERATE_INFOPLIST_FILE: true
PRODUCT_BUNDLE_IDENTIFIER: com.commandnotch.CommandNotchTests
PRODUCT_NAME: CommandNotchTests
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch"
BUNDLE_LOADER: "$(TEST_HOST)"
CommandNotchUITests:
type: bundle.ui-testing
platform: macOS
sources:
- path: CommandNotchUITests
dependencies:
- target: CommandNotch
settings:
base:
DEVELOPMENT_TEAM: G698BP272N
GENERATE_INFOPLIST_FILE: true
PRODUCT_BUNDLE_IDENTIFIER: com.commandnotch.CommandNotchUITests
PRODUCT_NAME: CommandNotchUITests
TEST_TARGET_NAME: CommandNotch