Compare commits
1 Commits
better-res
...
fe6c7d8c12
| Author | SHA1 | Date | |
|---|---|---|---|
|
fe6c7d8c12
|
@@ -8,9 +8,39 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0F4A88A33D93B6E100A1C001 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0F4A88A33D93B6E100A1C002 /* Assets.xcassets */; };
|
||||
A10000000000000000000001 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* AppSettings.swift */; };
|
||||
A10000000000000000000002 /* AppSettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* AppSettingsController.swift */; };
|
||||
A10000000000000000000003 /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* AppSettingsStore.swift */; };
|
||||
A10000000000000000000004 /* WorkspaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* WorkspaceController.swift */; };
|
||||
A10000000000000000000005 /* WorkspaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* WorkspaceRegistry.swift */; };
|
||||
A10000000000000000000006 /* WorkspaceSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* WorkspaceSummary.swift */; };
|
||||
A10000000000000000000007 /* AppSettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppSettingsStoreTests.swift */; };
|
||||
A10000000000000000000008 /* WorkspaceRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* WorkspaceRegistryTests.swift */; };
|
||||
A10000000000000000000009 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* XCTest.framework */; };
|
||||
A1000000000000000000000B /* ScreenContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000B /* ScreenContext.swift */; };
|
||||
A1000000000000000000000C /* ScreenRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000C /* ScreenRegistryTests.swift */; };
|
||||
A1000000000000000000000D /* ScreenRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000D /* ScreenRegistry.swift */; };
|
||||
A1000000000000000000000E /* WorkspaceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000E /* WorkspaceStore.swift */; };
|
||||
A1000000000000000000000F /* WorkspaceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000F /* WorkspaceStoreTests.swift */; };
|
||||
A10000000000000000000010 /* NotchOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* NotchOrchestrator.swift */; };
|
||||
A10000000000000000000011 /* NotchOrchestratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000011 /* NotchOrchestratorTests.swift */; };
|
||||
A10000000000000000000012 /* ScreenContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000012 /* ScreenContextTests.swift */; };
|
||||
A10000000000000000000013 /* WindowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000013 /* WindowCoordinator.swift */; };
|
||||
A10000000000000000000014 /* WindowFrameCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000014 /* WindowFrameCalculatorTests.swift */; };
|
||||
A10000000000000000000015 /* SettingsBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000015 /* SettingsBindings.swift */; };
|
||||
A10000000000000000000016 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000016 /* GeneralSettingsView.swift */; };
|
||||
A10000000000000000000017 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000017 /* AppearanceSettingsView.swift */; };
|
||||
A10000000000000000000018 /* AnimationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000018 /* AnimationSettingsView.swift */; };
|
||||
A10000000000000000000019 /* TerminalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000019 /* TerminalSettingsView.swift */; };
|
||||
A1000000000000000000001A /* HotkeySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001A /* HotkeySettingsView.swift */; };
|
||||
A1000000000000000000001B /* AboutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001B /* AboutSettingsView.swift */; };
|
||||
A1000000000000000000001C /* AppSettingsControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001C /* AppSettingsControllerTests.swift */; };
|
||||
A1000000000000000000001D /* WorkspaceSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001D /* WorkspaceSwitcherView.swift */; };
|
||||
A1000000000000000000001E /* WorkspacesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001E /* WorkspacesSettingsView.swift */; };
|
||||
A1000000000000000000001F /* CommandNotchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000001F /* CommandNotchUITests.swift */; };
|
||||
A10000000000000000000020 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* XCTest.framework */; };
|
||||
2213F430F3D8A88033607CD2 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6359CF9DDF89413440300D /* NotchSettings.swift */; };
|
||||
247C6F84E7ADE7AED43381E2 /* CommandNotchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B671125208055E5334CB85E /* CommandNotchApp.swift */; };
|
||||
26A767A10DDA77A690CC3C37 /* NotchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589421631401C819FE1A7BA9 /* NotchViewModel.swift */; };
|
||||
295653929D5B9C0E6C90D6D7 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 032AECA58EA4C274BE9F3320 /* SwiftTerm */; };
|
||||
37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B598809B19C892470DE7268 /* TerminalSession.swift */; };
|
||||
3A1F0C4BE9D84A5C8E2B7101 /* TerminalTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A1F0C4AE9D84A5C8E2B7101 /* TerminalTheme.swift */; };
|
||||
@@ -33,11 +63,60 @@
|
||||
F0130A88D1453CD199FA65D7 /* NSScreen+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
A60000000000000000000001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = F72A983360EF3F99042A4895 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 1485207FA11756EC2DF4F08B;
|
||||
remoteInfo = CommandNotch;
|
||||
};
|
||||
A60000000000000000000002 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = F72A983360EF3F99042A4895 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 1485207FA11756EC2DF4F08B;
|
||||
remoteInfo = CommandNotch;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
02FEFF9074A85F02C43D9408 /* NotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindow.swift; sourceTree = "<group>"; };
|
||||
0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
|
||||
0B567F3B5D006D2B35630CFF /* LaunchAtLoginHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginHelper.swift; sourceTree = "<group>"; };
|
||||
0F4A88A33D93B6E100A1C002 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A20000000000000000000001 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000002 /* AppSettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsController.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000003 /* AppSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStore.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000004 /* WorkspaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceController.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000005 /* WorkspaceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistry.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000006 /* WorkspaceSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSummary.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000007 /* CommandNotchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CommandNotchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A20000000000000000000008 /* AppSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStoreTests.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000009 /* WorkspaceRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistryTests.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000000A /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; };
|
||||
A2000000000000000000000B /* ScreenContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenContext.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000000C /* ScreenRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenRegistryTests.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000000D /* ScreenRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenRegistry.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000000E /* WorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStore.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000000F /* WorkspaceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStoreTests.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000010 /* NotchOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestrator.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000011 /* NotchOrchestratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestratorTests.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000012 /* ScreenContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenContextTests.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000013 /* WindowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCoordinator.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000014 /* WindowFrameCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrameCalculatorTests.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000015 /* SettingsBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBindings.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000016 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000017 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000018 /* AnimationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationSettingsView.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000019 /* TerminalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsView.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000001A /* HotkeySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeySettingsView.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000001B /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000001C /* AppSettingsControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsControllerTests.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000001D /* WorkspaceSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSwitcherView.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000001E /* WorkspacesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacesSettingsView.swift; sourceTree = "<group>"; };
|
||||
A2000000000000000000001F /* CommandNotchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchUITests.swift; sourceTree = "<group>"; };
|
||||
A20000000000000000000020 /* CommandNotchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CommandNotchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
15A290D4D21D6C01A583A372 /* ScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenManager.swift; sourceTree = "<group>"; };
|
||||
1E47000112562615C7E59489 /* SwiftTermView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTermView.swift; sourceTree = "<group>"; };
|
||||
1FC09C538CBE7C2D072008B2 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = "<group>"; };
|
||||
@@ -47,7 +126,6 @@
|
||||
490C53139360D970099D8F3D /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = "<group>"; };
|
||||
4B671125208055E5334CB85E /* CommandNotchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchApp.swift; sourceTree = "<group>"; };
|
||||
4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyBinding.swift; sourceTree = "<group>"; };
|
||||
589421631401C819FE1A7BA9 /* NotchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchViewModel.swift; sourceTree = "<group>"; };
|
||||
5C0779490DE9020FBBC464BE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
665CFC051CF185B71199608D /* CommandNotch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CommandNotch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7B598809B19C892470DE7268 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = "<group>"; };
|
||||
@@ -70,6 +148,22 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A40000000000000000000001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A10000000000000000000009 /* XCTest.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A40000000000000000000003 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A10000000000000000000020 /* XCTest.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -90,6 +184,7 @@
|
||||
EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */,
|
||||
15A290D4D21D6C01A583A372 /* ScreenManager.swift */,
|
||||
0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */,
|
||||
A20000000000000000000013 /* WindowCoordinator.swift */,
|
||||
);
|
||||
path = Managers;
|
||||
sourceTree = "<group>";
|
||||
@@ -98,20 +193,54 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
665CFC051CF185B71199608D /* CommandNotch.app */,
|
||||
A20000000000000000000007 /* CommandNotchTests.xctest */,
|
||||
A20000000000000000000020 /* CommandNotchUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A30000000000000000000001 /* CommandNotchTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A20000000000000000000008 /* AppSettingsStoreTests.swift */,
|
||||
A2000000000000000000001C /* AppSettingsControllerTests.swift */,
|
||||
A20000000000000000000012 /* ScreenContextTests.swift */,
|
||||
A2000000000000000000000C /* ScreenRegistryTests.swift */,
|
||||
A20000000000000000000011 /* NotchOrchestratorTests.swift */,
|
||||
A20000000000000000000014 /* WindowFrameCalculatorTests.swift */,
|
||||
A20000000000000000000009 /* WorkspaceRegistryTests.swift */,
|
||||
A2000000000000000000000F /* WorkspaceStoreTests.swift */,
|
||||
);
|
||||
path = CommandNotchTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A30000000000000000000002 /* CommandNotchUITests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A2000000000000000000001F /* CommandNotchUITests.swift */,
|
||||
);
|
||||
path = CommandNotchUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
869AD33E1CDEB9CBAD401BA6 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A20000000000000000000001 /* AppSettings.swift */,
|
||||
A20000000000000000000002 /* AppSettingsController.swift */,
|
||||
A20000000000000000000003 /* AppSettingsStore.swift */,
|
||||
4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */,
|
||||
AA6359CF9DDF89413440300D /* NotchSettings.swift */,
|
||||
2C5C99B7CD7F60E55844E40C /* NotchState.swift */,
|
||||
589421631401C819FE1A7BA9 /* NotchViewModel.swift */,
|
||||
A20000000000000000000010 /* NotchOrchestrator.swift */,
|
||||
A2000000000000000000000B /* ScreenContext.swift */,
|
||||
A2000000000000000000000D /* ScreenRegistry.swift */,
|
||||
BA6843B571B41986DE386F5F /* TerminalManager.swift */,
|
||||
7B598809B19C892470DE7268 /* TerminalSession.swift */,
|
||||
3A1F0C4AE9D84A5C8E2B7101 /* TerminalTheme.swift */,
|
||||
A20000000000000000000004 /* WorkspaceController.swift */,
|
||||
A20000000000000000000005 /* WorkspaceRegistry.swift */,
|
||||
A20000000000000000000006 /* WorkspaceSummary.swift */,
|
||||
A2000000000000000000000E /* WorkspaceStore.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
@@ -143,7 +272,16 @@
|
||||
C2B8955F4D0A1DAA7E60326A /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A2000000000000000000001B /* AboutSettingsView.swift */,
|
||||
A20000000000000000000017 /* AppearanceSettingsView.swift */,
|
||||
A20000000000000000000018 /* AnimationSettingsView.swift */,
|
||||
A20000000000000000000016 /* GeneralSettingsView.swift */,
|
||||
A2000000000000000000001A /* HotkeySettingsView.swift */,
|
||||
A20000000000000000000015 /* SettingsBindings.swift */,
|
||||
C5CB3313B230019D0E988AFE /* SettingsView.swift */,
|
||||
A20000000000000000000019 /* TerminalSettingsView.swift */,
|
||||
A2000000000000000000001E /* WorkspacesSettingsView.swift */,
|
||||
A2000000000000000000001D /* WorkspaceSwitcherView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -164,6 +302,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9E1CA4816F67033BBD52D8A3 /* CommandNotch */,
|
||||
A30000000000000000000001 /* CommandNotchTests */,
|
||||
A30000000000000000000002 /* CommandNotchUITests */,
|
||||
792DD4F8C079680683D8FF7A /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -191,6 +331,44 @@
|
||||
productReference = 665CFC051CF185B71199608D /* CommandNotch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
A50000000000000000000001 /* CommandNotchTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A90000000000000000000001 /* Build configuration list for PBXNativeTarget "CommandNotchTests" */;
|
||||
buildPhases = (
|
||||
A40000000000000000000002 /* Sources */,
|
||||
A40000000000000000000001 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
A70000000000000000000001 /* PBXTargetDependency */,
|
||||
);
|
||||
name = CommandNotchTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CommandNotchTests;
|
||||
productReference = A20000000000000000000007 /* CommandNotchTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
A50000000000000000000002 /* CommandNotchUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A90000000000000000000002 /* Build configuration list for PBXNativeTarget "CommandNotchUITests" */;
|
||||
buildPhases = (
|
||||
A40000000000000000000004 /* Sources */,
|
||||
A40000000000000000000003 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
A70000000000000000000002 /* PBXTargetDependency */,
|
||||
);
|
||||
name = CommandNotchUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CommandNotchUITests;
|
||||
productReference = A20000000000000000000020 /* CommandNotchUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -217,6 +395,8 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
1485207FA11756EC2DF4F08B /* CommandNotch */,
|
||||
A50000000000000000000001 /* CommandNotchTests */,
|
||||
A50000000000000000000002 /* CommandNotchUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -233,10 +413,36 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
A40000000000000000000002 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A10000000000000000000007 /* AppSettingsStoreTests.swift in Sources */,
|
||||
A1000000000000000000001C /* AppSettingsControllerTests.swift in Sources */,
|
||||
A10000000000000000000012 /* ScreenContextTests.swift in Sources */,
|
||||
A1000000000000000000000C /* ScreenRegistryTests.swift in Sources */,
|
||||
A10000000000000000000011 /* NotchOrchestratorTests.swift in Sources */,
|
||||
A10000000000000000000014 /* WindowFrameCalculatorTests.swift in Sources */,
|
||||
A10000000000000000000008 /* WorkspaceRegistryTests.swift in Sources */,
|
||||
A1000000000000000000000F /* WorkspaceStoreTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A40000000000000000000004 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A1000000000000000000001F /* CommandNotchUITests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F3C6D5CD1247D246A3F6F7AB /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A10000000000000000000001 /* AppSettings.swift in Sources */,
|
||||
A10000000000000000000002 /* AppSettingsController.swift in Sources */,
|
||||
A10000000000000000000003 /* AppSettingsStore.swift in Sources */,
|
||||
81A912E3E16165D999882078 /* AppDelegate.swift in Sources */,
|
||||
888C45C650327089EBD39B2E /* ContentView.swift in Sources */,
|
||||
247C6F84E7ADE7AED43381E2 /* CommandNotchApp.swift in Sources */,
|
||||
@@ -248,9 +454,11 @@
|
||||
2213F430F3D8A88033607CD2 /* NotchSettings.swift in Sources */,
|
||||
4566E6B87CB62AF5C8D4B9D8 /* NotchShape.swift in Sources */,
|
||||
A70FDB7EEEB895D475ED96E8 /* NotchState.swift in Sources */,
|
||||
26A767A10DDA77A690CC3C37 /* NotchViewModel.swift in Sources */,
|
||||
A10000000000000000000010 /* NotchOrchestrator.swift in Sources */,
|
||||
5B14FC23928E817FEB8D2A74 /* NotchWindow.swift in Sources */,
|
||||
8FC731CF99AB4C1C10C16FAB /* PopoutWindowController.swift in Sources */,
|
||||
A1000000000000000000000B /* ScreenContext.swift in Sources */,
|
||||
A1000000000000000000000D /* ScreenRegistry.swift in Sources */,
|
||||
BE5E64222CF5689AC7088683 /* ScreenManager.swift in Sources */,
|
||||
7DE94234DC42EAB79896E176 /* SettingsView.swift in Sources */,
|
||||
C4C93F2911B41BC19A2AE934 /* SettingsWindowController.swift in Sources */,
|
||||
@@ -259,11 +467,38 @@
|
||||
E9A064422790735E033E534F /* TerminalManager.swift in Sources */,
|
||||
37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */,
|
||||
3A1F0C4BE9D84A5C8E2B7101 /* TerminalTheme.swift in Sources */,
|
||||
A10000000000000000000013 /* WindowCoordinator.swift in Sources */,
|
||||
A10000000000000000000004 /* WorkspaceController.swift in Sources */,
|
||||
A10000000000000000000005 /* WorkspaceRegistry.swift in Sources */,
|
||||
A10000000000000000000006 /* WorkspaceSummary.swift in Sources */,
|
||||
A1000000000000000000000E /* WorkspaceStore.swift in Sources */,
|
||||
A1000000000000000000001B /* AboutSettingsView.swift in Sources */,
|
||||
A10000000000000000000017 /* AppearanceSettingsView.swift in Sources */,
|
||||
A10000000000000000000018 /* AnimationSettingsView.swift in Sources */,
|
||||
A10000000000000000000016 /* GeneralSettingsView.swift in Sources */,
|
||||
A1000000000000000000001A /* HotkeySettingsView.swift in Sources */,
|
||||
A10000000000000000000015 /* SettingsBindings.swift in Sources */,
|
||||
A10000000000000000000019 /* TerminalSettingsView.swift in Sources */,
|
||||
A1000000000000000000001E /* WorkspacesSettingsView.swift in Sources */,
|
||||
A1000000000000000000001D /* WorkspaceSwitcherView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
A70000000000000000000001 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 1485207FA11756EC2DF4F08B /* CommandNotch */;
|
||||
targetProxy = A60000000000000000000001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
A70000000000000000000002 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 1485207FA11756EC2DF4F08B /* CommandNotch */;
|
||||
targetProxy = A60000000000000000000002 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
0B8C784EF064E46C44076D6B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -375,6 +610,84 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A80000000000000000000001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@loader_path/../Frameworks",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.10;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch";
|
||||
TEST_TARGET_NAME = CommandNotch;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A80000000000000000000002 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@loader_path/../Frameworks",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_VERSION = 5.10;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch";
|
||||
TEST_TARGET_NAME = CommandNotch;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A80000000000000000000003 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = G698BP272N;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.10;
|
||||
TEST_TARGET_NAME = CommandNotch;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A80000000000000000000004 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = G698BP272N;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_VERSION = 5.10;
|
||||
TEST_TARGET_NAME = CommandNotch;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BC741C4C821EA399B645E547 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -444,6 +757,24 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
A90000000000000000000001 /* Build configuration list for PBXNativeTarget "CommandNotchTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A80000000000000000000001 /* Debug */,
|
||||
A80000000000000000000002 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
A90000000000000000000002 /* Build configuration list for PBXNativeTarget "CommandNotchUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A80000000000000000000003 /* Debug */,
|
||||
A80000000000000000000004 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
D1C4019FEAFC83BB053C9E6E /* Build configuration list for PBXProject "CommandNotch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
Binary file not shown.
@@ -9,6 +9,16 @@
|
||||
<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>
|
||||
|
||||
@@ -4,22 +4,36 @@ 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.
|
||||
UserDefaults.standard.set(LaunchAtLoginHelper.isEnabled, forKey: NotchSettings.Keys.launchAtLogin)
|
||||
settingsController.update {
|
||||
$0.display.launchAtLogin = LaunchAtLoginHelper.isEnabled
|
||||
}
|
||||
|
||||
ScreenManager.shared.start()
|
||||
observeDisplayPreference()
|
||||
observeSizePreferences()
|
||||
observeFontSizeChanges()
|
||||
observeTerminalThemeChanges()
|
||||
applyUITestLaunchBehaviorIfNeeded()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
@@ -30,7 +44,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
/// Only rebuild windows when the display-count preference changes.
|
||||
private func observeDisplayPreference() {
|
||||
UserDefaults.standard.publisher(for: \.showOnAllDisplays)
|
||||
settingsController.$settings
|
||||
.map(\.display.showOnAllDisplays)
|
||||
.removeDuplicates()
|
||||
.dropFirst()
|
||||
.sink { _ in
|
||||
@@ -41,7 +56,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
/// Reposition (not rebuild) when any sizing preference changes.
|
||||
private func observeSizePreferences() {
|
||||
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
|
||||
settingsController.$settings
|
||||
.map(\.display.layoutSignature)
|
||||
.removeDuplicates()
|
||||
.dropFirst()
|
||||
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
|
||||
.sink { _ in
|
||||
ScreenManager.shared.repositionWindows()
|
||||
@@ -51,38 +69,49 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
/// Live-update terminal font size across all sessions.
|
||||
private func observeFontSizeChanges() {
|
||||
UserDefaults.standard.publisher(for: \.terminalFontSize)
|
||||
settingsController.$settings
|
||||
.map(\.terminal.fontSize)
|
||||
.removeDuplicates()
|
||||
.sink { newSize in
|
||||
guard newSize > 0 else { return }
|
||||
TerminalManager.shared.updateAllFontSizes(CGFloat(newSize))
|
||||
WorkspaceRegistry.shared.updateAllWorkspacesFontSizes(CGFloat(newSize))
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Live-update terminal colors across all sessions.
|
||||
private func observeTerminalThemeChanges() {
|
||||
UserDefaults.standard.publisher(for: \.terminalTheme)
|
||||
settingsController.$settings
|
||||
.map(\.terminal.themeRawValue)
|
||||
.removeDuplicates()
|
||||
.sink { newTheme in
|
||||
TerminalManager.shared.updateAllThemes(TerminalTheme.resolve(newTheme))
|
||||
WorkspaceRegistry.shared.updateAllWorkspacesThemes(TerminalTheme.resolve(newTheme))
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private var launchArguments: [String] {
|
||||
ProcessInfo.processInfo.arguments
|
||||
}
|
||||
|
||||
// MARK: - KVO key paths
|
||||
|
||||
private extension UserDefaults {
|
||||
@objc var terminalFontSize: Double {
|
||||
double(forKey: NotchSettings.Keys.terminalFontSize)
|
||||
private var isRunningUITests: Bool {
|
||||
launchArguments.contains(UITestLaunchArgument.regularActivation)
|
||||
|| launchArguments.contains(UITestLaunchArgument.showSettings)
|
||||
|| launchArguments.contains(UITestLaunchArgument.openNotch)
|
||||
}
|
||||
|
||||
@objc var terminalTheme: String {
|
||||
string(forKey: NotchSettings.Keys.terminalTheme) ?? NotchSettings.Defaults.terminalTheme
|
||||
private func applyUITestLaunchBehaviorIfNeeded() {
|
||||
guard isRunningUITests else { return }
|
||||
|
||||
DispatchQueue.main.async { @MainActor in
|
||||
if self.launchArguments.contains(UITestLaunchArgument.showSettings) {
|
||||
SettingsWindowController.shared.showSettings()
|
||||
}
|
||||
|
||||
@objc var showOnAllDisplays: Bool {
|
||||
bool(forKey: NotchSettings.Keys.showOnAllDisplays)
|
||||
if self.launchArguments.contains(UITestLaunchArgument.openNotch),
|
||||
let screenID = ScreenRegistry.shared.activeScreenID() {
|
||||
ScreenManager.shared.openNotch(screenID: screenID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,19 @@ import SwiftUI
|
||||
struct CommandNotchApp: App {
|
||||
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
@AppStorage(NotchSettings.Keys.showMenuBarIcon)
|
||||
private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
|
||||
@StateObject private var settingsController = AppSettingsController.shared
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("CommandNotch", systemImage: "terminal", isInserted: $showMenuBarIcon) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import SwiftUI
|
||||
/// the single `.opacity()` on ContentView handles transparency.
|
||||
struct TabBar: View {
|
||||
|
||||
@ObservedObject var terminalManager: TerminalManager
|
||||
@ObservedObject var workspace: WorkspaceController
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(Array(terminalManager.tabs.enumerated()), id: \.element.id) { index, tab in
|
||||
ForEach(Array(workspace.tabs.enumerated()), id: \.element.id) { index, tab in
|
||||
tabButton(for: tab, at: index)
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,14 @@ struct TabBar: View {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
terminalManager.newTab()
|
||||
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)
|
||||
}
|
||||
@@ -36,7 +38,7 @@ struct TabBar: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func tabButton(for tab: TerminalSession, at index: Int) -> some View {
|
||||
let isActive = index == terminalManager.activeTabIndex
|
||||
let isActive = index == workspace.activeTabIndex
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(abbreviateTitle(tab.title))
|
||||
@@ -44,9 +46,9 @@ struct TabBar: View {
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(isActive ? .white : .white.opacity(0.5))
|
||||
|
||||
if isActive && terminalManager.tabs.count > 1 {
|
||||
if isActive && workspace.tabs.count > 1 {
|
||||
Button {
|
||||
terminalManager.closeTab(at: index)
|
||||
workspace.closeTab(at: index)
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
@@ -63,7 +65,7 @@ struct TabBar: View {
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
terminalManager.switchToTab(at: index)
|
||||
workspace.switchToTab(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,25 +9,11 @@ import SwiftTerm
|
||||
/// layering, no mismatched areas.
|
||||
struct ContentView: View {
|
||||
|
||||
@ObservedObject var vm: NotchViewModel
|
||||
@ObservedObject var terminalManager: TerminalManager
|
||||
@ObservedObject var screen: ScreenContext
|
||||
let orchestrator: NotchOrchestrator
|
||||
@ObservedObject private var settingsController = AppSettingsController.shared
|
||||
@ObservedObject private var screenRegistry = ScreenRegistry.shared
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
@AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover
|
||||
@AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration
|
||||
|
||||
@AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow
|
||||
@AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius
|
||||
@AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity
|
||||
@AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling
|
||||
@AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity
|
||||
@AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius
|
||||
|
||||
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverSpringResponse = NotchSettings.Defaults.hoverSpringResponse
|
||||
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping
|
||||
|
||||
@State private var hoverTask: Task<Void, Never>?
|
||||
@State private var resizeStartSize: CGSize?
|
||||
@State private var resizeStartMouseLocation: CGPoint?
|
||||
|
||||
@@ -36,18 +22,51 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var currentShape: NotchShape {
|
||||
vm.notchState == .open
|
||||
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: vm.notchSize.width,
|
||||
height: vm.notchState == .open ? vm.notchSize.height : vm.closedNotchSize.height,
|
||||
width: screen.notchSize.width,
|
||||
height: screen.notchState == .open ? screen.notchSize.height : screen.closedNotchSize.height,
|
||||
alignment: .top
|
||||
)
|
||||
.background(.black)
|
||||
@@ -56,7 +75,7 @@ struct ContentView: View {
|
||||
Rectangle().fill(.black).frame(height: 1)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if vm.notchState == .open {
|
||||
if screen.notchState == .open {
|
||||
resizeHandle
|
||||
}
|
||||
}
|
||||
@@ -68,22 +87,15 @@ struct ContentView: View {
|
||||
// so this one modifier makes it all uniformly transparent.
|
||||
.opacity(notchOpacity)
|
||||
.blur(radius: blurRadius)
|
||||
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState)
|
||||
.animation(sizeAnimation, value: vm.notchSize.width)
|
||||
.animation(sizeAnimation, value: vm.notchSize.height)
|
||||
.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) }
|
||||
.onChange(of: vm.isCloseTransitionActive) { _, isClosing in
|
||||
if isClosing {
|
||||
hoverTask?.cancel()
|
||||
} else {
|
||||
scheduleHoverOpenIfNeeded()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
hoverTask?.cancel()
|
||||
resizeStartSize = nil
|
||||
resizeStartMouseLocation = nil
|
||||
vm.endInteractiveResize()
|
||||
screen.endInteractiveResize()
|
||||
orchestrator.handleHoverChange(false, for: screen.id)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
@@ -93,18 +105,20 @@ struct ContentView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var notchBody: some View {
|
||||
if vm.notchState == .open {
|
||||
openContent
|
||||
WorkspaceScopedView(screen: screen, screenRegistry: screenRegistry) { workspace in
|
||||
if screen.notchState == .open {
|
||||
openContent(workspace: workspace)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
closedContent
|
||||
closedContent(workspace: workspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var closedContent: some View {
|
||||
private func closedContent(workspace: WorkspaceController) -> some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(abbreviate(terminalManager.activeTitle))
|
||||
Text(abbreviate(workspace.activeTitle))
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
@@ -128,15 +142,15 @@ struct ContentView: View {
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
if resizeStartSize == nil {
|
||||
resizeStartSize = vm.notchSize
|
||||
resizeStartSize = screen.notchSize
|
||||
resizeStartMouseLocation = NSEvent.mouseLocation
|
||||
vm.beginInteractiveResize()
|
||||
screen.beginInteractiveResize()
|
||||
}
|
||||
|
||||
guard let startSize = resizeStartSize,
|
||||
let startMouseLocation = resizeStartMouseLocation else { return }
|
||||
let currentMouseLocation = NSEvent.mouseLocation
|
||||
vm.resizeOpenNotch(
|
||||
screen.resizeOpenNotch(
|
||||
to: CGSize(
|
||||
width: startSize.width + ((currentMouseLocation.x - startMouseLocation.x) * 2),
|
||||
height: startSize.height + (startMouseLocation.y - currentMouseLocation.y)
|
||||
@@ -146,24 +160,25 @@ struct ContentView: View {
|
||||
.onEnded { _ in
|
||||
resizeStartSize = nil
|
||||
resizeStartMouseLocation = nil
|
||||
vm.endInteractiveResize()
|
||||
screen.endInteractiveResize()
|
||||
}
|
||||
}
|
||||
|
||||
private var sizeAnimation: Animation? {
|
||||
guard !vm.isUserResizing, !vm.isPresetResizing else { return nil }
|
||||
return vm.notchState == .open ? vm.openAnimation : vm.closeAnimation
|
||||
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 var openContent: some View {
|
||||
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 = terminalManager.detachActiveTab() {
|
||||
if let session = workspace.detachActiveTab() {
|
||||
PopoutWindowController.shared.popout(session: session)
|
||||
}
|
||||
}
|
||||
@@ -172,12 +187,13 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 10)
|
||||
.padding(.bottom, 2)
|
||||
.background(.black)
|
||||
|
||||
// Terminal — fills remaining space
|
||||
if let session = terminalManager.activeTab {
|
||||
if let session = workspace.activeTab {
|
||||
SwiftTermView(session: session)
|
||||
.id(session.id)
|
||||
.padding(.leading, 10)
|
||||
@@ -185,7 +201,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
// Tab bar
|
||||
TabBar(terminalManager: terminalManager)
|
||||
TabBar(workspace: workspace)
|
||||
}
|
||||
.background(.black)
|
||||
}
|
||||
@@ -199,38 +215,16 @@ struct ContentView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(help)
|
||||
.accessibilityIdentifier("notch.toolbar.\(icon)")
|
||||
.help(help)
|
||||
}
|
||||
|
||||
// MARK: - Hover
|
||||
|
||||
private func handleHover(_ hovering: Bool) {
|
||||
if hovering {
|
||||
withAnimation(hoverAnimation) { vm.isHovering = true }
|
||||
scheduleHoverOpenIfNeeded()
|
||||
} else {
|
||||
hoverTask?.cancel()
|
||||
withAnimation(hoverAnimation) { vm.isHovering = false }
|
||||
vm.clearHoverOpenSuppression()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleHoverOpenIfNeeded() {
|
||||
hoverTask?.cancel()
|
||||
guard openNotchOnHover,
|
||||
vm.notchState == .closed,
|
||||
!vm.isCloseTransitionActive,
|
||||
!vm.suppressHoverOpenUntilHoverExit,
|
||||
vm.isHovering else { return }
|
||||
|
||||
hoverTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(minimumHoverDuration * 1_000_000_000))
|
||||
guard !Task.isCancelled,
|
||||
vm.isHovering,
|
||||
vm.notchState == .closed,
|
||||
!vm.isCloseTransitionActive,
|
||||
!vm.suppressHoverOpenUntilHoverExit else { return }
|
||||
vm.requestOpen?()
|
||||
withAnimation(hoverAnimation) {
|
||||
orchestrator.handleHoverChange(hovering, for: screen.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,3 +245,33 @@ private struct ResizeHandleShape: Shape {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,18 +27,16 @@ extension NSScreen {
|
||||
|
||||
/// Computes the closed-state notch size for this screen,
|
||||
/// respecting the user's height mode and custom height preferences.
|
||||
func closedNotchSize() -> CGSize {
|
||||
let height = closedNotchHeight()
|
||||
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() -> CGFloat {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
private func closedNotchHeight(using settings: AppSettings.DisplaySettings) -> CGFloat {
|
||||
if hasNotch {
|
||||
let mode = NotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.notchHeightMode))
|
||||
let mode = NotchHeightMode(rawValue: settings.notchHeightMode)
|
||||
?? .matchRealNotchSize
|
||||
switch mode {
|
||||
case .matchRealNotchSize:
|
||||
@@ -46,16 +44,16 @@ extension NSScreen {
|
||||
case .matchMenuBar:
|
||||
return menuBarHeight()
|
||||
case .custom:
|
||||
return defaults.double(forKey: NotchSettings.Keys.notchHeight)
|
||||
return settings.notchHeight
|
||||
}
|
||||
} else {
|
||||
let mode = NonNotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.nonNotchHeightMode))
|
||||
let mode = NonNotchHeightMode(rawValue: settings.nonNotchHeightMode)
|
||||
?? .matchMenuBar
|
||||
switch mode {
|
||||
case .matchMenuBar:
|
||||
return menuBarHeight()
|
||||
case .custom:
|
||||
return defaults.double(forKey: NotchSettings.Keys.nonNotchHeight)
|
||||
return settings.nonNotchHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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. Tab-level hotkeys
|
||||
/// use a local `NSEvent` monitor (only fires when our app is active).
|
||||
@MainActor
|
||||
class HotkeyManager {
|
||||
|
||||
static let shared = HotkeyManager()
|
||||
@@ -27,37 +29,35 @@ class HotkeyManager {
|
||||
private var hotKeyRef: EventHotKeyRef?
|
||||
private var eventHandlerRef: EventHandlerRef?
|
||||
private var localMonitor: Any?
|
||||
private var defaultsObserver: NSObjectProtocol?
|
||||
private let settingsProvider: TerminalSessionConfigurationProviding
|
||||
private var settingsCancellable: AnyCancellable?
|
||||
|
||||
private init() {}
|
||||
init(settingsProvider: TerminalSessionConfigurationProviding? = nil) {
|
||||
self.settingsProvider = settingsProvider ?? AppSettingsController.shared
|
||||
}
|
||||
|
||||
// MARK: - Resolved bindings (live from UserDefaults)
|
||||
// MARK: - Resolved bindings from typed runtime settings
|
||||
|
||||
private var toggleBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyToggle) ?? .cmdReturn
|
||||
settingsProvider.hotkeySettings.toggle
|
||||
}
|
||||
private var newTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyNewTab) ?? .cmdT
|
||||
settingsProvider.hotkeySettings.newTab
|
||||
}
|
||||
private var closeTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyCloseTab) ?? .cmdW
|
||||
settingsProvider.hotkeySettings.closeTab
|
||||
}
|
||||
private var nextTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyNextTab) ?? .cmdShiftRB
|
||||
settingsProvider.hotkeySettings.nextTab
|
||||
}
|
||||
private var prevTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyPreviousTab) ?? .cmdShiftLB
|
||||
settingsProvider.hotkeySettings.previousTab
|
||||
}
|
||||
private var detachBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD
|
||||
settingsProvider.hotkeySettings.detachTab
|
||||
}
|
||||
private var sizePresets: [TerminalSizePreset] {
|
||||
TerminalSizePresetStore.load()
|
||||
}
|
||||
|
||||
private func binding(for key: String) -> HotkeyBinding? {
|
||||
guard let json = UserDefaults.standard.string(forKey: key) else { return nil }
|
||||
return HotkeyBinding.fromJSON(json)
|
||||
settingsProvider.terminalSizePresets
|
||||
}
|
||||
|
||||
// MARK: - Start / Stop
|
||||
@@ -73,10 +73,7 @@ class HotkeyManager {
|
||||
unregisterToggleHotkey()
|
||||
removeCarbonHandler()
|
||||
removeLocalMonitor()
|
||||
if let obs = defaultsObserver {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
defaultsObserver = nil
|
||||
}
|
||||
settingsCancellable = nil
|
||||
}
|
||||
|
||||
// MARK: - Carbon global hotkey (toggle)
|
||||
@@ -130,7 +127,7 @@ class HotkeyManager {
|
||||
|
||||
let binding = toggleBinding
|
||||
let carbonMods = carbonModifiers(from: binding.modifiers)
|
||||
var hotKeyID = EventHotKeyID(
|
||||
let hotKeyID = EventHotKeyID(
|
||||
signature: OSType(0x444E5452), // "DNTR"
|
||||
id: 1
|
||||
)
|
||||
@@ -163,13 +160,15 @@ class HotkeyManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-register the toggle hotkey whenever the user changes it in settings.
|
||||
/// Re-register the toggle hotkey whenever the typed settings change.
|
||||
private func observeToggleHotkeyChanges() {
|
||||
defaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let settingsProvider = settingsProvider as? AppSettingsController else { return }
|
||||
|
||||
settingsCancellable = settingsProvider.$settings
|
||||
.map(\.hotkeys.toggle)
|
||||
.removeDuplicates()
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.registerToggleHotkey()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// Manages one NotchWindow per connected display.
|
||||
/// Routes all open/close through centralized methods that handle
|
||||
/// window activation, key status, and first responder assignment
|
||||
/// so the terminal can receive keyboard input.
|
||||
/// Coordinates screen/workspace state with notch lifecycle and
|
||||
/// delegates raw window work to `WindowCoordinator`.
|
||||
@MainActor
|
||||
class ScreenManager: ObservableObject {
|
||||
|
||||
final class ScreenManager: ObservableObject {
|
||||
static let shared = ScreenManager()
|
||||
private let focusRetryDelay: TimeInterval = 0.01
|
||||
private let presetResizeFrameInterval: TimeInterval = 1.0 / 60.0
|
||||
|
||||
private(set) var windows: [String: NotchWindow] = [:]
|
||||
private(set) var viewModels: [String: NotchViewModel] = [:]
|
||||
private var presetResizeTimers: [String: Timer] = [:]
|
||||
|
||||
@AppStorage(NotchSettings.Keys.showOnAllDisplays)
|
||||
private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
|
||||
private let screenRegistry = ScreenRegistry.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()
|
||||
@@ -41,94 +38,54 @@ class ScreenManager: ObservableObject {
|
||||
// MARK: - Hotkey wiring
|
||||
|
||||
private func setupHotkeys() {
|
||||
let hk = HotkeyManager.shared
|
||||
let tm = TerminalManager.shared
|
||||
let hotkeyManager = HotkeyManager.shared
|
||||
|
||||
// Callbacks are invoked on the main thread by HotkeyManager.
|
||||
// MainActor.assumeIsolated lets us safely call @MainActor methods.
|
||||
hk.onToggle = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.toggleNotchOnActiveScreen() }
|
||||
hotkeyManager.onToggle = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.orchestrator.toggleOnActiveScreen() }
|
||||
}
|
||||
hk.onNewTab = { MainActor.assumeIsolated { tm.newTab() } }
|
||||
hk.onCloseTab = { MainActor.assumeIsolated { tm.closeActiveTab() } }
|
||||
hk.onNextTab = { MainActor.assumeIsolated { tm.nextTab() } }
|
||||
hk.onPreviousTab = { MainActor.assumeIsolated { tm.previousTab() } }
|
||||
hk.onDetachTab = { [weak self] in
|
||||
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.onDetachTab = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.detachActiveTab() }
|
||||
}
|
||||
hk.onApplySizePreset = { [weak self] preset in
|
||||
hotkeyManager.onApplySizePreset = { [weak self] preset in
|
||||
MainActor.assumeIsolated { self?.applySizePreset(preset) }
|
||||
}
|
||||
hk.onSwitchToTab = { index in
|
||||
MainActor.assumeIsolated { tm.switchToTab(at: index) }
|
||||
hotkeyManager.onSwitchToTab = { [weak self] index in
|
||||
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
|
||||
}
|
||||
|
||||
hk.start()
|
||||
hotkeyManager.start()
|
||||
}
|
||||
|
||||
// MARK: - Toggle
|
||||
|
||||
func toggleNotchOnActiveScreen() {
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
let targetScreen = NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }
|
||||
?? NSScreen.main
|
||||
guard let screen = targetScreen else { return }
|
||||
let uuid = screen.displayUUID
|
||||
|
||||
// Close any other open notch first
|
||||
for (otherUUID, otherVM) in viewModels where otherUUID != uuid {
|
||||
if otherVM.notchState == .open {
|
||||
closeNotch(screenUUID: otherUUID)
|
||||
}
|
||||
}
|
||||
|
||||
if let vm = viewModels[uuid] {
|
||||
if vm.notchState == .open {
|
||||
closeNotch(screenUUID: uuid)
|
||||
} else {
|
||||
openNotch(screenUUID: uuid)
|
||||
}
|
||||
}
|
||||
orchestrator.toggleOnActiveScreen()
|
||||
}
|
||||
|
||||
// MARK: - Open / Close
|
||||
|
||||
func openNotch(screenUUID: String) {
|
||||
guard let vm = viewModels[screenUUID],
|
||||
let window = windows[screenUUID] else { return }
|
||||
|
||||
vm.cancelCloseTransition()
|
||||
|
||||
withAnimation(vm.openAnimation) {
|
||||
vm.open()
|
||||
func openNotch(screenID: ScreenID) {
|
||||
orchestrator.open(screenID: screenID)
|
||||
}
|
||||
|
||||
window.isNotchOpen = true
|
||||
HotkeyManager.shared.isNotchOpen = true
|
||||
|
||||
// Activate the app so the window can become key.
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
|
||||
focusActiveTerminal(in: screenUUID)
|
||||
}
|
||||
|
||||
func closeNotch(screenUUID: String) {
|
||||
guard let vm = viewModels[screenUUID],
|
||||
let window = windows[screenUUID] else { return }
|
||||
|
||||
vm.beginCloseTransition()
|
||||
|
||||
withAnimation(vm.closeAnimation) {
|
||||
vm.close()
|
||||
}
|
||||
|
||||
window.isNotchOpen = false
|
||||
HotkeyManager.shared.isNotchOpen = false
|
||||
func closeNotch(screenID: ScreenID) {
|
||||
orchestrator.close(screenID: screenID)
|
||||
}
|
||||
|
||||
private func detachActiveTab() {
|
||||
if let session = TerminalManager.shared.detachActiveTab() {
|
||||
if let session = activeWorkspace().detachActiveTab() {
|
||||
DispatchQueue.main.async {
|
||||
PopoutWindowController.shared.popout(session: session)
|
||||
}
|
||||
@@ -136,235 +93,105 @@ class ScreenManager: ObservableObject {
|
||||
}
|
||||
|
||||
func applySizePreset(_ preset: TerminalSizePreset) {
|
||||
guard let (screenUUID, vm) = viewModels.first(where: { $0.value.notchState == .open }) else {
|
||||
UserDefaults.standard.set(preset.width, forKey: NotchSettings.Keys.openWidth)
|
||||
UserDefaults.standard.set(preset.height, forKey: NotchSettings.Keys.openHeight)
|
||||
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 = vm.notchSize
|
||||
let targetSize = vm.setStoredOpenSize(preset.size)
|
||||
animatePresetResize(for: screenUUID, from: startSize, to: targetSize, duration: vm.openAnimationDuration)
|
||||
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()
|
||||
|
||||
let screens: [NSScreen]
|
||||
if showOnAllDisplays {
|
||||
screens = NSScreen.screens
|
||||
} else {
|
||||
screens = [NSScreen.main].compactMap { $0 }
|
||||
}
|
||||
for screen in screens {
|
||||
for screen in visibleScreens() {
|
||||
createWindow(for: screen)
|
||||
}
|
||||
}
|
||||
|
||||
private func createWindow(for screen: NSScreen) {
|
||||
let uuid = screen.displayUUID
|
||||
let vm = NotchViewModel(screenUUID: uuid)
|
||||
let initialContentSize = vm.openNotchSize
|
||||
let screenID = screen.displayUUID
|
||||
guard let context = screenRegistry.screenContext(for: screenID) else { return }
|
||||
|
||||
let window = NotchWindow(
|
||||
contentRect: NSRect(origin: .zero, size: CGSize(width: initialContentSize.width + 40, height: initialContentSize.height + 20)),
|
||||
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
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 }
|
||||
|
||||
// Close the notch when the window loses focus
|
||||
window.onResignKey = { [weak self] in
|
||||
self?.closeNotch(screenUUID: uuid)
|
||||
self.windowCoordinator.focusActiveTerminal(for: screenID) { [weak self] in
|
||||
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
|
||||
}
|
||||
|
||||
// Wire the ViewModel callbacks so ContentView routes through us
|
||||
vm.requestOpen = { [weak self] in
|
||||
self?.openNotch(screenUUID: uuid)
|
||||
}
|
||||
vm.requestClose = { [weak self] in
|
||||
self?.closeNotch(screenUUID: uuid)
|
||||
}
|
||||
vm.requestWindowResize = { [weak self] in
|
||||
self?.updateWindowFrame(for: uuid, centerHorizontally: true)
|
||||
}
|
||||
|
||||
let hostingView = NSHostingView(
|
||||
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
|
||||
rootView: ContentView(
|
||||
screen: context,
|
||||
orchestrator: orchestrator
|
||||
)
|
||||
.preferredColorScheme(.dark)
|
||||
)
|
||||
let containerView = NSView(frame: NSRect(origin: .zero, size: window.frame.size))
|
||||
containerView.autoresizesSubviews = true
|
||||
containerView.wantsLayer = true
|
||||
containerView.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
|
||||
hostingView.frame = containerView.bounds
|
||||
hostingView.autoresizingMask = [.width, .height]
|
||||
containerView.addSubview(hostingView)
|
||||
window.contentView = containerView
|
||||
|
||||
windows[uuid] = window
|
||||
viewModels[uuid] = vm
|
||||
|
||||
updateWindowFrame(for: uuid, centerHorizontally: true)
|
||||
window.orderFrontRegardless()
|
||||
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() {
|
||||
for (uuid, window) in windows {
|
||||
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == uuid }) else { continue }
|
||||
guard let vm = viewModels[uuid] else { continue }
|
||||
screenRegistry.refreshConnectedScreens()
|
||||
|
||||
vm.refreshClosedSize()
|
||||
|
||||
updateWindowFrame(for: uuid, on: screen, window: window, centerHorizontally: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateWindowFrame(for screenUUID: String, centerHorizontally: Bool = false) {
|
||||
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }),
|
||||
let window = windows[screenUUID] else { return }
|
||||
updateWindowFrame(for: screenUUID, on: screen, window: window, centerHorizontally: centerHorizontally)
|
||||
}
|
||||
|
||||
private func updateWindowFrame(
|
||||
for screenUUID: String,
|
||||
on screen: NSScreen,
|
||||
window: NotchWindow,
|
||||
centerHorizontally: Bool = false
|
||||
) {
|
||||
let frame = targetWindowFrame(
|
||||
for: screenUUID,
|
||||
on: screen,
|
||||
window: window,
|
||||
centerHorizontally: centerHorizontally,
|
||||
contentSize: nil
|
||||
)
|
||||
guard !window.frame.equalTo(frame) else { return }
|
||||
window.setFrame(frame, display: false)
|
||||
}
|
||||
|
||||
private func targetWindowFrame(
|
||||
for screenUUID: String,
|
||||
on screen: NSScreen,
|
||||
window: NotchWindow,
|
||||
centerHorizontally: Bool,
|
||||
contentSize: CGSize?
|
||||
) -> NSRect {
|
||||
guard let vm = viewModels[screenUUID] else { return window.frame }
|
||||
|
||||
let shadowPadding: CGFloat = 20
|
||||
let openSize = contentSize ?? vm.openNotchSize
|
||||
let windowWidth = openSize.width + 40
|
||||
let windowHeight = openSize.height + shadowPadding
|
||||
let centeredX = screen.frame.origin.x + (screen.frame.width - windowWidth) / 2
|
||||
|
||||
let x: CGFloat = centerHorizontally || vm.notchState == .closed
|
||||
? centeredX
|
||||
: min(max(window.frame.minX, screen.frame.minX), screen.frame.maxX - windowWidth)
|
||||
|
||||
return NSRect(
|
||||
x: x,
|
||||
y: screen.frame.origin.y + screen.frame.height - windowHeight,
|
||||
width: windowWidth,
|
||||
height: windowHeight
|
||||
for context in screenRegistry.allScreens() {
|
||||
context.refreshClosedSize()
|
||||
windowCoordinator.repositionWindow(
|
||||
for: context.id,
|
||||
context: context,
|
||||
centerHorizontally: true
|
||||
)
|
||||
}
|
||||
|
||||
private func animatePresetResize(
|
||||
for screenUUID: String,
|
||||
from startSize: CGSize,
|
||||
to targetSize: CGSize,
|
||||
duration: TimeInterval
|
||||
) {
|
||||
cancelPresetResize(for: screenUUID)
|
||||
|
||||
guard let vm = viewModels[screenUUID] else { return }
|
||||
guard startSize != targetSize else {
|
||||
vm.notchSize = targetSize
|
||||
updateWindowFrame(for: screenUUID, centerHorizontally: true)
|
||||
return
|
||||
}
|
||||
|
||||
vm.isPresetResizing = true
|
||||
let startTime = CACurrentMediaTime()
|
||||
let duration = max(duration, presetResizeFrameInterval)
|
||||
|
||||
let timer = Timer(timeInterval: presetResizeFrameInterval, repeats: true) { [weak self] timer in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, let vm = self.viewModels[screenUUID] else {
|
||||
timer.invalidate()
|
||||
return
|
||||
}
|
||||
|
||||
let elapsed = CACurrentMediaTime() - startTime
|
||||
let progress = min(1, elapsed / duration)
|
||||
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)
|
||||
)
|
||||
|
||||
vm.notchSize = size
|
||||
self.updateWindowFrame(for: screenUUID, contentSize: size, centerHorizontally: true)
|
||||
|
||||
if progress >= 1 {
|
||||
vm.notchSize = targetSize
|
||||
vm.isPresetResizing = false
|
||||
self.updateWindowFrame(for: screenUUID, contentSize: targetSize, centerHorizontally: true)
|
||||
self.presetResizeTimers[screenUUID] = nil
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
presetResizeTimers[screenUUID] = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
timer.fire()
|
||||
}
|
||||
|
||||
private func cancelPresetResize(for screenUUID: String) {
|
||||
presetResizeTimers[screenUUID]?.invalidate()
|
||||
presetResizeTimers[screenUUID] = nil
|
||||
viewModels[screenUUID]?.isPresetResizing = false
|
||||
}
|
||||
|
||||
private func updateWindowFrame(
|
||||
for screenUUID: String,
|
||||
contentSize: CGSize,
|
||||
centerHorizontally: Bool = false
|
||||
) {
|
||||
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }),
|
||||
let window = windows[screenUUID] else { return }
|
||||
|
||||
let frame = targetWindowFrame(
|
||||
for: screenUUID,
|
||||
on: screen,
|
||||
window: window,
|
||||
centerHorizontally: centerHorizontally,
|
||||
contentSize: contentSize
|
||||
)
|
||||
guard !window.frame.equalTo(frame) else { return }
|
||||
window.setFrame(frame, display: false)
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
private func cleanupAllWindows() {
|
||||
for (_, timer) in presetResizeTimers {
|
||||
timer.invalidate()
|
||||
}
|
||||
presetResizeTimers.removeAll()
|
||||
for (_, window) in windows {
|
||||
window.orderOut(nil)
|
||||
window.close()
|
||||
}
|
||||
windows.removeAll()
|
||||
viewModels.removeAll()
|
||||
orchestrator.cancelAllPendingWork()
|
||||
windowCoordinator.cleanupAllWindows()
|
||||
}
|
||||
|
||||
// MARK: - Screen observation
|
||||
@@ -372,33 +199,62 @@ class ScreenManager: ObservableObject {
|
||||
private func observeScreenChanges() {
|
||||
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
|
||||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||
.sink { [weak self] _ in self?.handleScreenConfigurationChange() }
|
||||
.sink { [weak self] _ in
|
||||
self?.handleScreenConfigurationChange()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handleScreenConfigurationChange() {
|
||||
let currentUUIDs = Set(NSScreen.screens.map { $0.displayUUID })
|
||||
let knownUUIDs = Set(windows.keys)
|
||||
if currentUUIDs != knownUUIDs {
|
||||
screenRegistry.refreshConnectedScreens()
|
||||
|
||||
let currentScreenIDs = Set(visibleScreens().map(\.displayUUID))
|
||||
let knownScreenIDs = windowCoordinator.windowScreenIDs()
|
||||
|
||||
if currentScreenIDs != knownScreenIDs {
|
||||
rebuildWindows()
|
||||
} else {
|
||||
repositionWindows()
|
||||
}
|
||||
}
|
||||
|
||||
private func focusActiveTerminal(in screenUUID: String, attemptsRemaining: Int = 12) {
|
||||
guard let window = windows[screenUUID],
|
||||
let terminalView = TerminalManager.shared.activeTab?.terminalView else { return }
|
||||
private func activeWorkspace() -> WorkspaceController {
|
||||
guard let screenID = screenRegistry.activeScreenID() else {
|
||||
return WorkspaceRegistry.shared.defaultWorkspaceController
|
||||
}
|
||||
|
||||
if terminalView.window === window {
|
||||
window.makeFirstResponder(terminalView)
|
||||
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
|
||||
}
|
||||
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
windowCoordinator.presentOpen(for: screenID) { [weak self] in
|
||||
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in
|
||||
self?.focusActiveTerminal(in: screenUUID, attemptsRemaining: attemptsRemaining - 1)
|
||||
}
|
||||
func performClosePresentation(for screenID: ScreenID) {
|
||||
guard screenRegistry.screenContext(for: screenID) != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
windowCoordinator.presentClose(for: screenID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
defer: false
|
||||
)
|
||||
win.title = "CommandNotch Settings"
|
||||
win.identifier = NSUserInterfaceItemIdentifier("settings.window")
|
||||
win.contentView = hostingView
|
||||
win.center()
|
||||
win.delegate = self
|
||||
|
||||
291
Downterm/CommandNotch/Managers/WindowCoordinator.swift
Normal file
291
Downterm/CommandNotch/Managers/WindowCoordinator.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
161
Downterm/CommandNotch/Models/AppSettings.swift
Normal file
161
Downterm/CommandNotch/Models/AppSettings.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
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,
|
||||
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 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 }
|
||||
}
|
||||
74
Downterm/CommandNotch/Models/AppSettingsController.swift
Normal file
74
Downterm/CommandNotch/Models/AppSettingsController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
136
Downterm/CommandNotch/Models/AppSettingsStore.swift
Normal file
136
Downterm/CommandNotch/Models/AppSettingsStore.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
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),
|
||||
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.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
|
||||
}
|
||||
}
|
||||
189
Downterm/CommandNotch/Models/NotchOrchestrator.swift
Normal file
189
Downterm/CommandNotch/Models/NotchOrchestrator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ enum NotchSettings {
|
||||
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"
|
||||
@@ -212,17 +214,14 @@ enum TerminalSizePresetStore {
|
||||
static func load() -> [TerminalSizePreset] {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let json = defaults.string(forKey: NotchSettings.Keys.terminalSizePresets),
|
||||
let data = json.data(using: .utf8),
|
||||
let presets = try? JSONDecoder().decode([TerminalSizePreset].self, from: data) else {
|
||||
let presets = decodePresets(from: json) else {
|
||||
return defaultPresets()
|
||||
}
|
||||
return presets
|
||||
}
|
||||
|
||||
static func save(_ presets: [TerminalSizePreset]) {
|
||||
guard let data = try? JSONEncoder().encode(presets),
|
||||
let json = String(data: data, encoding: .utf8) else { return }
|
||||
UserDefaults.standard.set(json, forKey: NotchSettings.Keys.terminalSizePresets)
|
||||
UserDefaults.standard.set(encodePresets(presets), forKey: NotchSettings.Keys.terminalSizePresets)
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
@@ -234,11 +233,7 @@ enum TerminalSizePresetStore {
|
||||
}
|
||||
|
||||
static func defaultPresetsJSON() -> String {
|
||||
guard let data = try? JSONEncoder().encode(defaultPresets()),
|
||||
let json = String(data: data, encoding: .utf8) else {
|
||||
return "[]"
|
||||
}
|
||||
return json
|
||||
encodePresets(defaultPresets())
|
||||
}
|
||||
|
||||
static func suggestedHotkey(for presets: [TerminalSizePreset]) -> HotkeyBinding? {
|
||||
@@ -259,4 +254,17 @@ enum TerminalSizePresetStore {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Per-screen observable state that drives the notch UI.
|
||||
@MainActor
|
||||
class NotchViewModel: ObservableObject {
|
||||
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 screenUUID: String
|
||||
|
||||
@Published var notchState: NotchState = .closed
|
||||
@Published var notchSize: CGSize
|
||||
@Published var closedNotchSize: CGSize
|
||||
@Published var isHovering: Bool = false
|
||||
@Published var isCloseTransitionActive: Bool = false
|
||||
@Published var suppressHoverOpenUntilHoverExit: Bool = false
|
||||
@Published var isUserResizing: Bool = false
|
||||
@Published var isPresetResizing: Bool = false
|
||||
|
||||
let terminalManager = TerminalManager.shared
|
||||
|
||||
/// Set by ScreenManager — routes open/close through proper
|
||||
/// window activation so the terminal receives keyboard input.
|
||||
var requestOpen: (() -> Void)?
|
||||
var requestClose: (() -> Void)?
|
||||
var requestWindowResize: (() -> Void)?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
|
||||
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
|
||||
|
||||
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openSpringResponse = NotchSettings.Defaults.openSpringResponse
|
||||
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openSpringDamping = NotchSettings.Defaults.openSpringDamping
|
||||
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeSpringResponse = NotchSettings.Defaults.closeSpringResponse
|
||||
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeSpringDamping = NotchSettings.Defaults.closeSpringDamping
|
||||
@AppStorage(NotchSettings.Keys.resizeAnimationDuration) private var resizeAnimationDurationSetting = NotchSettings.Defaults.resizeAnimationDuration
|
||||
|
||||
private var closeTransitionTask: Task<Void, Never>?
|
||||
|
||||
var openAnimation: Animation {
|
||||
.spring(response: openSpringResponse, dampingFraction: openSpringDamping)
|
||||
}
|
||||
var closeAnimation: Animation {
|
||||
.spring(response: closeSpringResponse, dampingFraction: closeSpringDamping)
|
||||
}
|
||||
var openAnimationDuration: TimeInterval {
|
||||
max(0.05, resizeAnimationDurationSetting)
|
||||
}
|
||||
|
||||
init(screenUUID: String) {
|
||||
self.screenUUID = screenUUID
|
||||
let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main
|
||||
let closed = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32)
|
||||
self.closedNotchSize = closed
|
||||
self.notchSize = closed
|
||||
}
|
||||
|
||||
func open() {
|
||||
let size = openNotchSize
|
||||
openWidth = size.width
|
||||
openHeight = size.height
|
||||
notchSize = size
|
||||
notchState = .open
|
||||
}
|
||||
|
||||
func close() {
|
||||
refreshClosedSize()
|
||||
notchSize = closedNotchSize
|
||||
notchState = .closed
|
||||
}
|
||||
|
||||
func refreshClosedSize() {
|
||||
let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main
|
||||
closedNotchSize = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32)
|
||||
}
|
||||
|
||||
var openNotchSize: CGSize {
|
||||
clampedOpenSize(CGSize(width: openWidth, height: openHeight))
|
||||
}
|
||||
|
||||
func beginInteractiveResize() {
|
||||
isUserResizing = true
|
||||
}
|
||||
|
||||
func resizeOpenNotch(to proposedSize: CGSize) {
|
||||
setOpenSize(proposedSize, notifyWindowResize: true)
|
||||
}
|
||||
|
||||
func endInteractiveResize() {
|
||||
isUserResizing = false
|
||||
}
|
||||
|
||||
func applySizePreset(_ preset: TerminalSizePreset, notifyWindowResize: Bool = true) {
|
||||
setOpenSize(preset.size, notifyWindowResize: notifyWindowResize)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setStoredOpenSize(_ proposedSize: CGSize) -> CGSize {
|
||||
let clampedSize = clampedOpenSize(proposedSize)
|
||||
openWidth = clampedSize.width
|
||||
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 = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }) ?? NSScreen.main else {
|
||||
return Self.minimumOpenWidth
|
||||
}
|
||||
return max(Self.minimumOpenWidth, screen.frame.width - Self.windowHorizontalPadding)
|
||||
}
|
||||
|
||||
private var maximumAllowedHeight: CGFloat {
|
||||
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }) ?? NSScreen.main else {
|
||||
return Self.minimumOpenHeight
|
||||
}
|
||||
return max(Self.minimumOpenHeight, screen.frame.height - Self.windowVerticalPadding)
|
||||
}
|
||||
|
||||
var closeInteractionLockDuration: TimeInterval {
|
||||
max(closeSpringResponse + 0.2, 0.35)
|
||||
}
|
||||
|
||||
func beginCloseTransition() {
|
||||
closeTransitionTask?.cancel()
|
||||
isCloseTransitionActive = true
|
||||
if isHovering {
|
||||
suppressHoverOpenUntilHoverExit = true
|
||||
}
|
||||
|
||||
let delay = closeInteractionLockDuration
|
||||
closeTransitionTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.isCloseTransitionActive = false
|
||||
self.closeTransitionTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
func cancelCloseTransition() {
|
||||
closeTransitionTask?.cancel()
|
||||
closeTransitionTask = nil
|
||||
isCloseTransitionActive = false
|
||||
}
|
||||
|
||||
func clearHoverOpenSuppression() {
|
||||
suppressHoverOpenUntilHoverExit = false
|
||||
}
|
||||
|
||||
deinit {
|
||||
closeTransitionTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGFloat {
|
||||
func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
|
||||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
222
Downterm/CommandNotch/Models/ScreenContext.swift
Normal file
222
Downterm/CommandNotch/Models/ScreenContext.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
268
Downterm/CommandNotch/Models/ScreenRegistry.swift
Normal file
268
Downterm/CommandNotch/Models/ScreenRegistry.swift
Normal 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 {}
|
||||
@@ -1,118 +1,75 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Manages multiple terminal tabs. Singleton shared across all screens —
|
||||
/// whichever notch is currently open displays these tabs.
|
||||
/// Compatibility adapter for the legacy single-workspace architecture.
|
||||
/// New code should use `WorkspaceRegistry` + `WorkspaceController`.
|
||||
@MainActor
|
||||
class TerminalManager: ObservableObject {
|
||||
|
||||
static let shared = TerminalManager()
|
||||
|
||||
@Published var tabs: [TerminalSession] = []
|
||||
@Published var activeTabIndex: Int = 0
|
||||
|
||||
@AppStorage(NotchSettings.Keys.terminalFontSize)
|
||||
private var fontSize: Double = NotchSettings.Defaults.terminalFontSize
|
||||
@AppStorage(NotchSettings.Keys.terminalTheme)
|
||||
private var theme: String = NotchSettings.Defaults.terminalTheme
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var workspaceCancellable: AnyCancellable?
|
||||
|
||||
private init() {
|
||||
newTab()
|
||||
workspaceCancellable = WorkspaceRegistry.shared.defaultWorkspaceController.objectWillChange
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Active tab
|
||||
private var workspace: WorkspaceController {
|
||||
WorkspaceRegistry.shared.defaultWorkspaceController
|
||||
}
|
||||
|
||||
var tabs: [TerminalSession] {
|
||||
workspace.tabs
|
||||
}
|
||||
|
||||
var activeTabIndex: Int {
|
||||
workspace.activeTabIndex
|
||||
}
|
||||
|
||||
var activeTab: TerminalSession? {
|
||||
guard tabs.indices.contains(activeTabIndex) else { return nil }
|
||||
return tabs[activeTabIndex]
|
||||
workspace.activeTab
|
||||
}
|
||||
|
||||
/// Short title for the closed notch bar — the active tab's process name.
|
||||
var activeTitle: String {
|
||||
activeTab?.title ?? "shell"
|
||||
workspace.activeTitle
|
||||
}
|
||||
|
||||
// MARK: - Tab operations
|
||||
|
||||
func newTab() {
|
||||
let session = TerminalSession(
|
||||
fontSize: CGFloat(fontSize),
|
||||
theme: TerminalTheme.resolve(theme)
|
||||
)
|
||||
|
||||
// Forward title changes to trigger view updates in this manager
|
||||
session.$title
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
.store(in: &cancellables)
|
||||
|
||||
tabs.append(session)
|
||||
activeTabIndex = tabs.count - 1
|
||||
}
|
||||
|
||||
func closeTab(at index: Int) {
|
||||
guard tabs.indices.contains(index) else { return }
|
||||
tabs[index].terminate()
|
||||
tabs.remove(at: index)
|
||||
|
||||
// Adjust active index
|
||||
if tabs.isEmpty {
|
||||
newTab()
|
||||
} else if activeTabIndex >= tabs.count {
|
||||
activeTabIndex = tabs.count - 1
|
||||
}
|
||||
workspace.newTab()
|
||||
}
|
||||
|
||||
func closeActiveTab() {
|
||||
closeTab(at: activeTabIndex)
|
||||
workspace.closeActiveTab()
|
||||
}
|
||||
|
||||
func closeTab(at index: Int) {
|
||||
workspace.closeTab(at: index)
|
||||
}
|
||||
|
||||
func switchToTab(at index: Int) {
|
||||
guard tabs.indices.contains(index) else { return }
|
||||
activeTabIndex = index
|
||||
workspace.switchToTab(at: index)
|
||||
}
|
||||
|
||||
func nextTab() {
|
||||
guard tabs.count > 1 else { return }
|
||||
activeTabIndex = (activeTabIndex + 1) % tabs.count
|
||||
workspace.nextTab()
|
||||
}
|
||||
|
||||
func previousTab() {
|
||||
guard tabs.count > 1 else { return }
|
||||
activeTabIndex = (activeTabIndex - 1 + tabs.count) % tabs.count
|
||||
}
|
||||
|
||||
/// Removes the tab at the given index and returns the session so it
|
||||
/// can be hosted in a pop-out window.
|
||||
func detachTab(at index: Int) -> TerminalSession? {
|
||||
guard tabs.indices.contains(index) else { return nil }
|
||||
let session = tabs.remove(at: index)
|
||||
|
||||
if tabs.isEmpty {
|
||||
newTab()
|
||||
} else if activeTabIndex >= tabs.count {
|
||||
activeTabIndex = tabs.count - 1
|
||||
}
|
||||
|
||||
return session
|
||||
workspace.previousTab()
|
||||
}
|
||||
|
||||
func detachActiveTab() -> TerminalSession? {
|
||||
detachTab(at: activeTabIndex)
|
||||
workspace.detachActiveTab()
|
||||
}
|
||||
|
||||
/// Updates font size on all existing terminal sessions.
|
||||
func updateAllFontSizes(_ size: CGFloat) {
|
||||
for tab in tabs {
|
||||
tab.updateFontSize(size)
|
||||
}
|
||||
workspace.updateAllFontSizes(size)
|
||||
}
|
||||
|
||||
func updateAllThemes(_ theme: TerminalTheme) {
|
||||
for tab in tabs {
|
||||
tab.applyTheme(theme)
|
||||
}
|
||||
workspace.updateAllThemes(theme)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,21 @@ import Combine
|
||||
|
||||
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
|
||||
@MainActor
|
||||
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, TerminalViewDelegate {
|
||||
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @preconcurrency TerminalViewDelegate {
|
||||
|
||||
let id = UUID()
|
||||
let terminalView: TerminalView
|
||||
private var process: LocalProcess?
|
||||
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) {
|
||||
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
|
||||
@@ -35,21 +37,21 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, Termina
|
||||
let shellName = (shellPath as NSString).lastPathComponent
|
||||
let loginExecName = "-\(shellName)"
|
||||
|
||||
FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory())
|
||||
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
|
||||
execName: loginExecName,
|
||||
currentDirectory: NSHomeDirectory()
|
||||
)
|
||||
process = proc
|
||||
title = shellName
|
||||
}
|
||||
|
||||
private func resolveShell() -> String {
|
||||
let custom = UserDefaults.standard.string(forKey: NotchSettings.Keys.terminalShell) ?? ""
|
||||
let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
|
||||
return custom
|
||||
}
|
||||
|
||||
167
Downterm/CommandNotch/Models/WorkspaceController.swift
Normal file
167
Downterm/CommandNotch/Models/WorkspaceController.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
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 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.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)
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
150
Downterm/CommandNotch/Models/WorkspaceRegistry.swift
Normal file
150
Downterm/CommandNotch/Models/WorkspaceRegistry.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
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()
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
59
Downterm/CommandNotch/Models/WorkspaceStore.swift
Normal file
59
Downterm/CommandNotch/Models/WorkspaceStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
27
Downterm/CommandNotch/Models/WorkspaceSummary.swift
Normal file
27
Downterm/CommandNotch/Models/WorkspaceSummary.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
typealias WorkspaceID = UUID
|
||||
|
||||
struct WorkspaceSummary: Identifiable, Equatable, Codable {
|
||||
var id: WorkspaceID
|
||||
var name: String
|
||||
var createdAt: Date
|
||||
|
||||
init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
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?
|
||||
}
|
||||
29
Downterm/CommandNotch/Views/AboutSettingsView.swift
Normal file
29
Downterm/CommandNotch/Views/AboutSettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
72
Downterm/CommandNotch/Views/AnimationSettingsView.swift
Normal file
72
Downterm/CommandNotch/Views/AnimationSettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Downterm/CommandNotch/Views/AppearanceSettingsView.swift
Normal file
51
Downterm/CommandNotch/Views/AppearanceSettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
107
Downterm/CommandNotch/Views/GeneralSettingsView.swift
Normal file
107
Downterm/CommandNotch/Views/GeneralSettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
36
Downterm/CommandNotch/Views/HotkeySettingsView.swift
Normal file
36
Downterm/CommandNotch/Views/HotkeySettingsView.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
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 {
|
||||
Text("⌘1–9 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Reset to Defaults") {
|
||||
settingsController.update {
|
||||
$0.hotkeys = AppSettings.default.hotkeys
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
13
Downterm/CommandNotch/Views/SettingsBindings.swift
Normal file
13
Downterm/CommandNotch/Views/SettingsBindings.swift
Normal 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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About.
|
||||
struct SettingsView: View {
|
||||
|
||||
@State private var selectedTab: SettingsTab = .general
|
||||
|
||||
var body: some View {
|
||||
@@ -11,6 +8,7 @@ struct SettingsView: View {
|
||||
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)
|
||||
@@ -26,495 +24,64 @@ struct SettingsView: View {
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
switch selectedTab {
|
||||
case .general: GeneralSettingsView()
|
||||
case .appearance: AppearanceSettingsView()
|
||||
case .animation: AnimationSettingsView()
|
||||
case .terminal: TerminalSettingsView()
|
||||
case .hotkeys: HotkeySettingsView()
|
||||
case .about: AboutSettingsView()
|
||||
case .general:
|
||||
GeneralSettingsView()
|
||||
case .appearance:
|
||||
AppearanceSettingsView()
|
||||
case .workspaces:
|
||||
WorkspacesSettingsView()
|
||||
case .animation:
|
||||
AnimationSettingsView()
|
||||
case .terminal:
|
||||
TerminalSettingsView()
|
||||
case .hotkeys:
|
||||
HotkeySettingsView()
|
||||
case .about:
|
||||
AboutSettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tabs
|
||||
|
||||
enum SettingsTab: String, CaseIterable, Identifiable {
|
||||
case general, appearance, animation, terminal, hotkeys, about
|
||||
case general, appearance, workspaces, animation, terminal, hotkeys, about
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .general: return "General"
|
||||
case .appearance: return "Appearance"
|
||||
case .animation: return "Animation"
|
||||
case .terminal: return "Terminal"
|
||||
case .hotkeys: return "Hotkeys"
|
||||
case .about: return "About"
|
||||
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: return "gearshape"
|
||||
case .appearance: return "paintbrush"
|
||||
case .animation: return "bolt.fill"
|
||||
case .terminal: return "terminal"
|
||||
case .hotkeys: return "keyboard"
|
||||
case .about: return "info.circle"
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - General
|
||||
|
||||
struct GeneralSettingsView: View {
|
||||
|
||||
@AppStorage(NotchSettings.Keys.showOnAllDisplays) private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
|
||||
@AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover
|
||||
@AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration
|
||||
@AppStorage(NotchSettings.Keys.showMenuBarIcon) private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
|
||||
@AppStorage(NotchSettings.Keys.launchAtLogin) private var launchAtLogin = NotchSettings.Defaults.launchAtLogin
|
||||
@AppStorage(NotchSettings.Keys.enableGestures) private var enableGestures = NotchSettings.Defaults.enableGestures
|
||||
@AppStorage(NotchSettings.Keys.gestureSensitivity) private var gestureSensitivity = NotchSettings.Defaults.gestureSensitivity
|
||||
|
||||
@AppStorage(NotchSettings.Keys.notchHeightMode) private var notchHeightMode = NotchSettings.Defaults.notchHeightMode
|
||||
@AppStorage(NotchSettings.Keys.notchHeight) private var notchHeight = NotchSettings.Defaults.notchHeight
|
||||
@AppStorage(NotchSettings.Keys.nonNotchHeightMode) private var nonNotchHeightMode = NotchSettings.Defaults.nonNotchHeightMode
|
||||
@AppStorage(NotchSettings.Keys.nonNotchHeight) private var nonNotchHeight = NotchSettings.Defaults.nonNotchHeight
|
||||
|
||||
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
|
||||
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
|
||||
|
||||
private var maxOpenWidth: Double {
|
||||
max(openWidth, Double((NSScreen.screens.map { $0.frame.width - 40 }.max() ?? 1600).rounded()))
|
||||
}
|
||||
|
||||
private var maxOpenHeight: Double {
|
||||
max(openHeight, Double((NSScreen.screens.map { $0.frame.height - 20 }.max() ?? 900).rounded()))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Display") {
|
||||
Toggle("Show on all displays", isOn: $showOnAllDisplays)
|
||||
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
|
||||
Toggle("Launch at login", isOn: $launchAtLogin)
|
||||
.onChange(of: launchAtLogin) { _, newValue in
|
||||
LaunchAtLoginHelper.setEnabled(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Hover Behavior") {
|
||||
Toggle("Open notch on hover", isOn: $openNotchOnHover)
|
||||
if openNotchOnHover {
|
||||
HStack {
|
||||
Text("Hover delay")
|
||||
Slider(value: $minimumHoverDuration, in: 0.0...2.0, step: 0.05)
|
||||
Text(String(format: "%.2fs", minimumHoverDuration))
|
||||
.monospacedDigit().frame(width: 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Gestures") {
|
||||
Toggle("Enable gestures", isOn: $enableGestures)
|
||||
if enableGestures {
|
||||
HStack {
|
||||
Text("Sensitivity")
|
||||
Slider(value: $gestureSensitivity, in: 0.1...1.0, step: 0.05)
|
||||
Text(String(format: "%.2f", gestureSensitivity))
|
||||
.monospacedDigit().frame(width: 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Closed Notch Size") {
|
||||
Picker("Notch screens", selection: $notchHeightMode) {
|
||||
ForEach(NotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
|
||||
}
|
||||
if notchHeightMode == NotchHeightMode.custom.rawValue {
|
||||
HStack {
|
||||
Text("Custom height")
|
||||
Slider(value: $notchHeight, in: 16...64, step: 1)
|
||||
Text("\(Int(notchHeight))pt").monospacedDigit().frame(width: 50)
|
||||
}
|
||||
}
|
||||
Picker("Non-notch screens", selection: $nonNotchHeightMode) {
|
||||
ForEach(NonNotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
|
||||
}
|
||||
if nonNotchHeightMode == NonNotchHeightMode.custom.rawValue {
|
||||
HStack {
|
||||
Text("Custom height")
|
||||
Slider(value: $nonNotchHeight, in: 16...64, step: 1)
|
||||
Text("\(Int(nonNotchHeight))pt").monospacedDigit().frame(width: 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Open Notch Size") {
|
||||
HStack {
|
||||
Text("Width")
|
||||
Slider(value: $openWidth, in: 320...maxOpenWidth, step: 10)
|
||||
Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60)
|
||||
}
|
||||
HStack {
|
||||
Text("Height")
|
||||
Slider(value: $openHeight, in: 140...maxOpenHeight, step: 10)
|
||||
Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Appearance
|
||||
|
||||
struct AppearanceSettingsView: View {
|
||||
|
||||
@AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow
|
||||
@AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius
|
||||
@AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity
|
||||
@AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling
|
||||
@AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity
|
||||
@AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Shadow") {
|
||||
Toggle("Enable shadow", isOn: $enableShadow)
|
||||
if enableShadow {
|
||||
HStack {
|
||||
Text("Radius")
|
||||
Slider(value: $shadowRadius, in: 0...30, step: 1)
|
||||
Text(String(format: "%.0f", shadowRadius)).monospacedDigit().frame(width: 40)
|
||||
}
|
||||
HStack {
|
||||
Text("Opacity")
|
||||
Slider(value: $shadowOpacity, in: 0...1, step: 0.05)
|
||||
Text(String(format: "%.2f", shadowOpacity)).monospacedDigit().frame(width: 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Shape") {
|
||||
Toggle("Scale corner radii when open", isOn: $cornerRadiusScaling)
|
||||
}
|
||||
Section("Opacity & Blur") {
|
||||
HStack {
|
||||
Text("Notch opacity")
|
||||
Slider(value: $notchOpacity, in: 0...1, step: 0.05)
|
||||
Text(String(format: "%.2f", notchOpacity)).monospacedDigit().frame(width: 50)
|
||||
}
|
||||
HStack {
|
||||
Text("Blur radius")
|
||||
Slider(value: $blurRadius, in: 0...20, step: 0.5)
|
||||
Text(String(format: "%.1f", blurRadius)).monospacedDigit().frame(width: 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Animation
|
||||
|
||||
struct AnimationSettingsView: View {
|
||||
|
||||
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openResponse = NotchSettings.Defaults.openSpringResponse
|
||||
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openDamping = NotchSettings.Defaults.openSpringDamping
|
||||
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeResponse = NotchSettings.Defaults.closeSpringResponse
|
||||
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping
|
||||
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse
|
||||
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping
|
||||
@AppStorage(NotchSettings.Keys.resizeAnimationDuration) private var resizeDuration = NotchSettings.Defaults.resizeAnimationDuration
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Open Animation") {
|
||||
springControls(response: $openResponse, damping: $openDamping)
|
||||
}
|
||||
Section("Close Animation") {
|
||||
springControls(response: $closeResponse, damping: $closeDamping)
|
||||
}
|
||||
Section("Hover Animation") {
|
||||
springControls(response: $hoverResponse, damping: $hoverDamping)
|
||||
}
|
||||
Section("Resize Animation") {
|
||||
durationControl(duration: $resizeDuration)
|
||||
}
|
||||
Section {
|
||||
Button("Reset to Defaults") {
|
||||
openResponse = NotchSettings.Defaults.openSpringResponse
|
||||
openDamping = NotchSettings.Defaults.openSpringDamping
|
||||
closeResponse = NotchSettings.Defaults.closeSpringResponse
|
||||
closeDamping = NotchSettings.Defaults.closeSpringDamping
|
||||
hoverResponse = NotchSettings.Defaults.hoverSpringResponse
|
||||
hoverDamping = NotchSettings.Defaults.hoverSpringDamping
|
||||
resizeDuration = NotchSettings.Defaults.resizeAnimationDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal
|
||||
|
||||
struct TerminalSettingsView: View {
|
||||
|
||||
@AppStorage(NotchSettings.Keys.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize
|
||||
@AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell
|
||||
@AppStorage(NotchSettings.Keys.terminalTheme) private var theme = NotchSettings.Defaults.terminalTheme
|
||||
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
|
||||
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
|
||||
|
||||
@State private var sizePresets = TerminalSizePresetStore.load()
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Font") {
|
||||
HStack {
|
||||
Text("Font size")
|
||||
Slider(value: $fontSize, in: 8...28, step: 1)
|
||||
Text("\(Int(fontSize))pt").monospacedDigit().frame(width: 50)
|
||||
}
|
||||
}
|
||||
Section("Colors") {
|
||||
Picker("Theme", selection: $theme) {
|
||||
ForEach(TerminalTheme.allCases) { terminalTheme in
|
||||
Text(terminalTheme.label).tag(terminalTheme.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
Text(TerminalTheme.resolve(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: $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: openWidth,
|
||||
currentOpenHeight: openHeight,
|
||||
onDelete: { deletePreset(id: preset.id) },
|
||||
onApply: { applyPreset(preset) }
|
||||
)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Add Preset") {
|
||||
sizePresets.append(
|
||||
TerminalSizePreset(
|
||||
name: "Preset \(sizePresets.count + 1)",
|
||||
width: openWidth,
|
||||
height: 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)
|
||||
.onChange(of: sizePresets) { _, newValue in
|
||||
TerminalSizePresetStore.save(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func deletePreset(id: UUID) {
|
||||
sizePresets.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
private func applyPreset(_ preset: TerminalSizePreset) {
|
||||
openWidth = preset.width
|
||||
openHeight = preset.height
|
||||
ScreenManager.shared.applySizePreset(preset)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hotkeys
|
||||
|
||||
struct HotkeySettingsView: View {
|
||||
|
||||
@State private var toggleBinding = loadBinding(NotchSettings.Keys.hotkeyToggle, fallback: .cmdReturn)
|
||||
@State private var newTabBinding = loadBinding(NotchSettings.Keys.hotkeyNewTab, fallback: .cmdT)
|
||||
@State private var closeTabBinding = loadBinding(NotchSettings.Keys.hotkeyCloseTab, fallback: .cmdW)
|
||||
@State private var nextTabBinding = loadBinding(NotchSettings.Keys.hotkeyNextTab, fallback: .cmdShiftRB)
|
||||
@State private var prevTabBinding = loadBinding(NotchSettings.Keys.hotkeyPreviousTab, fallback: .cmdShiftLB)
|
||||
@State private var detachBinding = loadBinding(NotchSettings.Keys.hotkeyDetachTab, fallback: .cmdD)
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Global") {
|
||||
HotkeyRecorderView(label: "Toggle notch", binding: bindAndSave($toggleBinding, key: NotchSettings.Keys.hotkeyToggle))
|
||||
}
|
||||
|
||||
Section("Terminal Tabs (active when notch is open)") {
|
||||
HotkeyRecorderView(label: "New tab", binding: bindAndSave($newTabBinding, key: NotchSettings.Keys.hotkeyNewTab))
|
||||
HotkeyRecorderView(label: "Close tab", binding: bindAndSave($closeTabBinding, key: NotchSettings.Keys.hotkeyCloseTab))
|
||||
HotkeyRecorderView(label: "Next tab", binding: bindAndSave($nextTabBinding, key: NotchSettings.Keys.hotkeyNextTab))
|
||||
HotkeyRecorderView(label: "Previous tab", binding: bindAndSave($prevTabBinding, key: NotchSettings.Keys.hotkeyPreviousTab))
|
||||
HotkeyRecorderView(label: "Detach tab", binding: bindAndSave($detachBinding, key: NotchSettings.Keys.hotkeyDetachTab))
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("⌘1–9 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Reset to Defaults") {
|
||||
toggleBinding = .cmdReturn
|
||||
newTabBinding = .cmdT
|
||||
closeTabBinding = .cmdW
|
||||
nextTabBinding = .cmdShiftRB
|
||||
prevTabBinding = .cmdShiftLB
|
||||
detachBinding = .cmdD
|
||||
|
||||
save(.cmdReturn, key: NotchSettings.Keys.hotkeyToggle)
|
||||
save(.cmdT, key: NotchSettings.Keys.hotkeyNewTab)
|
||||
save(.cmdW, key: NotchSettings.Keys.hotkeyCloseTab)
|
||||
save(.cmdShiftRB, key: NotchSettings.Keys.hotkeyNextTab)
|
||||
save(.cmdShiftLB, key: NotchSettings.Keys.hotkeyPreviousTab)
|
||||
save(.cmdD, key: NotchSettings.Keys.hotkeyDetachTab)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
/// Creates a binding that saves to UserDefaults on every change.
|
||||
private func bindAndSave(_ state: Binding<HotkeyBinding>, key: String) -> Binding<HotkeyBinding> {
|
||||
Binding(
|
||||
get: { state.wrappedValue },
|
||||
set: { newValue in
|
||||
state.wrappedValue = newValue
|
||||
save(newValue, key: key)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func save(_ binding: HotkeyBinding, key: String) {
|
||||
UserDefaults.standard.set(binding.toJSON(), forKey: key)
|
||||
}
|
||||
|
||||
private static func loadBinding(_ key: String, fallback: HotkeyBinding) -> HotkeyBinding {
|
||||
guard let json = UserDefaults.standard.string(forKey: key),
|
||||
let b = HotkeyBinding.fromJSON(json) else { return fallback }
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - About
|
||||
|
||||
struct AboutSettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "terminal")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("CommandNotch")
|
||||
.font(.largeTitle.bold())
|
||||
Text("Version 0.3.0")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("A drop-down terminal that lives in your notch.")
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 40)
|
||||
}
|
||||
}
|
||||
|
||||
152
Downterm/CommandNotch/Views/TerminalSettingsView.swift
Normal file
152
Downterm/CommandNotch/Views/TerminalSettingsView.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TerminalSettingsView: View {
|
||||
@ObservedObject private var settingsController = AppSettingsController.shared
|
||||
|
||||
private var sizePresetsBinding: Binding<[TerminalSizePreset]> {
|
||||
Binding(
|
||||
get: {
|
||||
TerminalSizePresetStore.decodePresets(
|
||||
from: settingsController.settings.terminal.sizePresetsJSON
|
||||
) ?? TerminalSizePresetStore.loadDefaults()
|
||||
},
|
||||
set: { newValue in
|
||||
settingsController.update {
|
||||
$0.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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(sizePresetsBinding) { $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") {
|
||||
var presets = sizePresetsBinding.wrappedValue
|
||||
presets.append(
|
||||
TerminalSizePreset(
|
||||
name: "Preset \(presets.count + 1)",
|
||||
width: settingsController.settings.display.openWidth,
|
||||
height: settingsController.settings.display.openHeight,
|
||||
hotkey: TerminalSizePresetStore.suggestedHotkey(for: presets)
|
||||
)
|
||||
)
|
||||
sizePresetsBinding.wrappedValue = presets
|
||||
}
|
||||
|
||||
Button("Reset Presets") {
|
||||
sizePresetsBinding.wrappedValue = 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)
|
||||
}
|
||||
|
||||
private func deletePreset(id: UUID) {
|
||||
sizePresetsBinding.wrappedValue.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 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)
|
||||
}
|
||||
}
|
||||
161
Downterm/CommandNotch/Views/WorkspaceSwitcherView.swift
Normal file
161
Downterm/CommandNotch/Views/WorkspaceSwitcherView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
271
Downterm/CommandNotch/Views/WorkspacesSettingsView.swift
Normal file
271
Downterm/CommandNotch/Views/WorkspacesSettingsView.swift
Normal file
@@ -0,0 +1,271 @@
|
||||
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()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Save Name") {
|
||||
renameSelectedWorkspace()
|
||||
}
|
||||
.accessibilityIdentifier("settings.workspaces.save-name")
|
||||
|
||||
Button("New Workspace") {
|
||||
createWorkspace()
|
||||
}
|
||||
.accessibilityIdentifier("settings.workspaces.new")
|
||||
}
|
||||
}
|
||||
|
||||
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 deleteSelectedWorkspace() {
|
||||
guard let effectiveSelectedWorkspaceID,
|
||||
let fallbackWorkspaceID = screenRegistry.deleteWorkspace(
|
||||
effectiveSelectedWorkspaceID,
|
||||
preferredFallback: workspaceRegistry.defaultWorkspaceID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
self.selectedWorkspaceID = fallbackWorkspaceID
|
||||
renameDraft = workspaceRegistry.summary(for: fallbackWorkspaceID)?.name ?? ""
|
||||
}
|
||||
}
|
||||
42
Downterm/CommandNotchTests/AppSettingsControllerTests.swift
Normal file
42
Downterm/CommandNotchTests/AppSettingsControllerTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
38
Downterm/CommandNotchTests/AppSettingsStoreTests.swift
Normal file
38
Downterm/CommandNotchTests/AppSettingsStoreTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
248
Downterm/CommandNotchTests/NotchOrchestratorTests.swift
Normal file
248
Downterm/CommandNotchTests/NotchOrchestratorTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
67
Downterm/CommandNotchTests/ScreenContextTests.swift
Normal file
67
Downterm/CommandNotchTests/ScreenContextTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
319
Downterm/CommandNotchTests/ScreenRegistryTests.swift
Normal file
319
Downterm/CommandNotchTests/ScreenRegistryTests.swift
Normal 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.")
|
||||
}
|
||||
}
|
||||
32
Downterm/CommandNotchTests/WindowFrameCalculatorTests.swift
Normal file
32
Downterm/CommandNotchTests/WindowFrameCalculatorTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
122
Downterm/CommandNotchTests/WorkspaceRegistryTests.swift
Normal file
122
Downterm/CommandNotchTests/WorkspaceRegistryTests.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
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"])
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
36
Downterm/CommandNotchTests/WorkspaceStoreTests.swift
Normal file
36
Downterm/CommandNotchTests/WorkspaceStoreTests.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
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"),
|
||||
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)
|
||||
}
|
||||
}
|
||||
103
Downterm/CommandNotchUITests/CommandNotchUITests.swift
Normal file
103
Downterm/CommandNotchUITests/CommandNotchUITests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -46,3 +46,31 @@ targets:
|
||||
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
|
||||
|
||||
885
docs/workspace-architecture-spec.md
Normal file
885
docs/workspace-architecture-spec.md
Normal file
@@ -0,0 +1,885 @@
|
||||
# Workspace Architecture Spec
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the target architecture for refactoring CommandNotch into a maintainable, testable macOS app with:
|
||||
|
||||
- explicit runtime state ownership
|
||||
- virtual workspaces shared across screens
|
||||
- typed settings instead of `UserDefaults` as the event bus
|
||||
- reduced singleton coupling
|
||||
- a realistic testing strategy for logic, integration, and UI
|
||||
|
||||
This is the implementation reference for the refactor.
|
||||
|
||||
## Goals
|
||||
|
||||
- Preserve the current product behavior where possible.
|
||||
- Support multiple virtual workspaces.
|
||||
- Allow multiple screens to point at the same workspace.
|
||||
- Allow screens to switch workspaces independently.
|
||||
- Keep screen-specific UI state local to each screen.
|
||||
- Make business logic unit testable without real `NSWindow`, `NSScreen`, or `UserDefaults`.
|
||||
- Keep AppKit/SwiftUI at the edges.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Multi-window rendering of the same live terminal instance at the same time.
|
||||
- Full state restoration of shell process contents across launches.
|
||||
- Perfect backward compatibility for internal architecture.
|
||||
- Solving all visual polish issues during the refactor.
|
||||
|
||||
## Constraints
|
||||
|
||||
- A `TerminalView` cannot safely belong to more than one window/view hierarchy at once.
|
||||
- A `LocalProcess`-backed session is inherently stateful and UI-coupled through SwiftTerm.
|
||||
- The app is macOS-only and already depends on AppKit + SwiftUI + SwiftTerm.
|
||||
|
||||
## Target Mental Model
|
||||
|
||||
There are three layers of state:
|
||||
|
||||
1. Global app state
|
||||
Global settings, hotkeys, launch behavior, workspace metadata, screen assignments.
|
||||
2. Workspace state
|
||||
Tabs, active tab, terminal sessions, workspace title, workspace-local behavior.
|
||||
3. Screen state
|
||||
Which workspace a screen is viewing, notch geometry, open/closed state, hover state, focus state.
|
||||
|
||||
The current app blurs these boundaries. The refactor makes them explicit.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
### Composition Root
|
||||
|
||||
`AppDelegate` becomes the composition root. It wires concrete implementations together and passes them into coordinators/controllers. It should be the only place that knows about most concrete types.
|
||||
|
||||
### Core Modules
|
||||
|
||||
- `AppSettingsStore`
|
||||
Loads and saves global settings. Publishes typed changes.
|
||||
- `WorkspaceRegistry`
|
||||
Owns all workspaces and workspace metadata.
|
||||
- `ScreenRegistry`
|
||||
Tracks connected screens and their screen-local state.
|
||||
- `NotchOrchestrator`
|
||||
Coordinates notch lifecycle per screen.
|
||||
- `WindowCoordinator`
|
||||
Owns AppKit windows/panels and binds them to screen models.
|
||||
- `HotkeyService`
|
||||
Registers global and local hotkeys and emits typed intents.
|
||||
- `SettingsCoordinator`
|
||||
Presents settings UI.
|
||||
- `PopoutCoordinator`
|
||||
Presents detached terminal windows.
|
||||
|
||||
### UI Boundaries
|
||||
|
||||
- SwiftUI views render state and emit intents only.
|
||||
- AppKit controllers own `NSWindow`, `NSPanel`, `NSHostingView`.
|
||||
- SwiftTerm integration is behind a terminal session abstraction.
|
||||
|
||||
## Ownership Model
|
||||
|
||||
### AppSettings
|
||||
|
||||
Owns:
|
||||
|
||||
- app-wide appearance settings
|
||||
- hotkey bindings
|
||||
- launch-at-login
|
||||
- menu bar visibility
|
||||
- default workspace behavior
|
||||
- workspace assignment persistence
|
||||
|
||||
Does not own:
|
||||
|
||||
- transient window geometry
|
||||
- active terminal sessions
|
||||
- per-screen hover/open state
|
||||
|
||||
### Workspace
|
||||
|
||||
Owns:
|
||||
|
||||
- `workspaceID`
|
||||
- display name
|
||||
- ordered tabs
|
||||
- active tab selection
|
||||
- detached tab metadata if retained
|
||||
- workspace-local terminal state
|
||||
|
||||
Does not own:
|
||||
|
||||
- screen assignment
|
||||
- notch geometry
|
||||
- whether a screen is open or closed
|
||||
|
||||
### ScreenContext
|
||||
|
||||
Owns:
|
||||
|
||||
- `screenID`
|
||||
- assigned `workspaceID`
|
||||
- notch presentation state
|
||||
- current notch frame/size
|
||||
- hover state
|
||||
- local focus state
|
||||
- local transition state
|
||||
|
||||
Does not own:
|
||||
|
||||
- tabs
|
||||
- terminal session collection
|
||||
- app-wide settings
|
||||
|
||||
## Required Domain Types
|
||||
|
||||
### Identifiers
|
||||
|
||||
```swift
|
||||
typealias WorkspaceID = UUID
|
||||
typealias ScreenID = String
|
||||
typealias TabID = UUID
|
||||
typealias SessionID = UUID
|
||||
```
|
||||
|
||||
### App Settings
|
||||
|
||||
```swift
|
||||
struct AppSettings: Equatable, Codable {
|
||||
var showMenuBarIcon: Bool
|
||||
var showOnAllDisplays: Bool
|
||||
var launchAtLogin: Bool
|
||||
|
||||
var appearance: AppearanceSettings
|
||||
var animation: AnimationSettings
|
||||
var terminal: TerminalSettings
|
||||
var hotkeys: HotkeySettings
|
||||
}
|
||||
```
|
||||
|
||||
### Workspace Summary
|
||||
|
||||
```swift
|
||||
struct WorkspaceSummary: Equatable, Codable, Identifiable {
|
||||
var id: WorkspaceID
|
||||
var name: String
|
||||
var createdAt: Date
|
||||
}
|
||||
```
|
||||
|
||||
### Workspace Assignment
|
||||
|
||||
```swift
|
||||
struct ScreenWorkspaceAssignment: Equatable, Codable {
|
||||
var screenID: ScreenID
|
||||
var workspaceID: WorkspaceID
|
||||
}
|
||||
```
|
||||
|
||||
### Screen UI State
|
||||
|
||||
```swift
|
||||
struct ScreenUIState: Equatable {
|
||||
var screenID: ScreenID
|
||||
var workspaceID: WorkspaceID
|
||||
var notchState: NotchPresentationState
|
||||
var notchSize: CGSize
|
||||
var closedNotchSize: CGSize
|
||||
var isHovering: Bool
|
||||
var isFocused: Bool
|
||||
var transitionState: NotchTransitionState
|
||||
}
|
||||
```
|
||||
|
||||
### Workspace State
|
||||
|
||||
```swift
|
||||
struct WorkspaceState: Identifiable {
|
||||
var id: WorkspaceID
|
||||
var name: String
|
||||
var tabs: [TerminalTabState]
|
||||
var activeTabID: TabID?
|
||||
}
|
||||
```
|
||||
|
||||
### Tab State
|
||||
|
||||
```swift
|
||||
struct TerminalTabState: Identifiable {
|
||||
var id: TabID
|
||||
var sessionID: SessionID
|
||||
var title: String
|
||||
}
|
||||
```
|
||||
|
||||
### Notch Lifecycle State
|
||||
|
||||
```swift
|
||||
enum NotchPresentationState: Equatable {
|
||||
case closed
|
||||
case open
|
||||
}
|
||||
|
||||
enum NotchTransitionState: Equatable {
|
||||
case idle
|
||||
case opening
|
||||
case closing
|
||||
case resizingUser
|
||||
case resizingPreset
|
||||
}
|
||||
```
|
||||
|
||||
## Target Runtime Objects
|
||||
|
||||
### `AppController`
|
||||
|
||||
Top-level coordinator created by `AppDelegate`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- boot app services
|
||||
- respond to lifecycle events
|
||||
- connect hotkey intents to workspace/screen actions
|
||||
- own references to main long-lived services
|
||||
|
||||
### `WorkspaceRegistry`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- create workspace
|
||||
- delete workspace
|
||||
- rename workspace
|
||||
- fetch workspace by id
|
||||
- publish workspace list changes
|
||||
- ensure at least one workspace exists
|
||||
|
||||
Suggested API:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
protocol WorkspaceRegistryType: AnyObject {
|
||||
var workspaceSummariesPublisher: AnyPublisher<[WorkspaceSummary], Never> { get }
|
||||
|
||||
func allWorkspaceSummaries() -> [WorkspaceSummary]
|
||||
func workspaceController(for id: WorkspaceID) -> WorkspaceControllerType?
|
||||
func createWorkspace(named: String?) -> WorkspaceID
|
||||
func deleteWorkspace(id: WorkspaceID)
|
||||
func renameWorkspace(id: WorkspaceID, name: String)
|
||||
func ensureWorkspaceExists() -> WorkspaceID
|
||||
}
|
||||
```
|
||||
|
||||
### `WorkspaceController`
|
||||
|
||||
Replaces most of `TerminalManager`.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- own tabs for a single workspace
|
||||
- create and close tabs
|
||||
- detach tabs
|
||||
- switch active tab
|
||||
- update session appearance when settings change
|
||||
- publish workspace state
|
||||
|
||||
Suggested API:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
protocol WorkspaceControllerType: ObservableObject {
|
||||
var state: WorkspaceState { get }
|
||||
|
||||
func newTab()
|
||||
func closeTab(id: TabID)
|
||||
func closeActiveTab()
|
||||
func switchToTab(id: TabID)
|
||||
func switchToTab(index: Int)
|
||||
func nextTab()
|
||||
func previousTab()
|
||||
func detachActiveTab() -> TerminalSessionType?
|
||||
func updateTheme(_ theme: TerminalTheme)
|
||||
func updateFontSize(_ size: CGFloat)
|
||||
}
|
||||
```
|
||||
|
||||
### `ScreenRegistry`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- discover connected screens
|
||||
- maintain `ScreenContext` for each screen
|
||||
- maintain screen-to-workspace assignment
|
||||
- rebuild state on screen changes
|
||||
|
||||
Suggested API:
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
protocol ScreenRegistryType: AnyObject {
|
||||
var screenContextsPublisher: AnyPublisher<[ScreenContext], Never> { get }
|
||||
|
||||
func allScreens() -> [ScreenContext]
|
||||
func screenContext(for id: ScreenID) -> ScreenContext?
|
||||
func assignWorkspace(_ workspaceID: WorkspaceID, to screenID: ScreenID)
|
||||
func activeScreenID() -> ScreenID?
|
||||
func refreshConnectedScreens()
|
||||
}
|
||||
```
|
||||
|
||||
### `ScreenContext`
|
||||
|
||||
Observable object for one physical display.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- store local UI state
|
||||
- expose a current `workspaceID`
|
||||
- emit user intents for local notch behavior
|
||||
|
||||
This should replace today’s overloaded `NotchViewModel`.
|
||||
|
||||
### `NotchOrchestrator`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- open/close notch for a screen
|
||||
- coordinate focus, hover, suppression, and transitions
|
||||
- enforce transition rules
|
||||
- drive frame updates indirectly through window coordination
|
||||
|
||||
The orchestrator should own the state machine, not `ContentView`.
|
||||
|
||||
### `WindowCoordinator`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- create/destroy one `NotchWindow` per screen
|
||||
- bind `ScreenContext` to window content
|
||||
- update window frames
|
||||
- respond to `resignKey`
|
||||
- focus the correct terminal view
|
||||
|
||||
This keeps AppKit-specific code out of registry/controller classes.
|
||||
|
||||
### `HotkeyService`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- register global hotkey bindings
|
||||
- observe only hotkey-related settings changes
|
||||
- emit typed commands
|
||||
|
||||
Suggested intent enum:
|
||||
|
||||
```swift
|
||||
enum AppCommand {
|
||||
case toggleNotch(screenID: ScreenID?)
|
||||
case newTab(workspaceID: WorkspaceID)
|
||||
case closeTab(workspaceID: WorkspaceID)
|
||||
case nextTab(workspaceID: WorkspaceID)
|
||||
case previousTab(workspaceID: WorkspaceID)
|
||||
case switchToTab(workspaceID: WorkspaceID, index: Int)
|
||||
case detachTab(workspaceID: WorkspaceID)
|
||||
case applySizePreset(screenID: ScreenID, presetID: UUID)
|
||||
case switchWorkspace(screenID: ScreenID, workspaceID: WorkspaceID)
|
||||
case createWorkspace(screenID: ScreenID)
|
||||
}
|
||||
```
|
||||
|
||||
## Terminal Session Design
|
||||
|
||||
### Current Problem
|
||||
|
||||
`TerminalSession` mixes:
|
||||
|
||||
- process lifecycle
|
||||
- SwiftTerm view ownership
|
||||
- delegate callbacks
|
||||
- some UI-facing published state
|
||||
|
||||
This makes reuse difficult.
|
||||
|
||||
### Target Split
|
||||
|
||||
Introduce:
|
||||
|
||||
- `TerminalSession`
|
||||
Process + session metadata + delegate event translation
|
||||
- `TerminalViewHost`
|
||||
Owns a `TerminalView` for one active screen/window
|
||||
|
||||
Minimal first-step compromise:
|
||||
|
||||
- Keep `TerminalSession` owning one `TerminalView`
|
||||
- Enforce that only one screen at a time can actively render a given workspace
|
||||
- Make that rule explicit in the architecture
|
||||
|
||||
Recommended v1 rule:
|
||||
|
||||
- many screens may point at the same workspace
|
||||
- only one screen may have that workspace open and focused at a time
|
||||
|
||||
This avoids trying to mirror one live terminal view into multiple windows.
|
||||
|
||||
## Settings Design
|
||||
|
||||
### Current Problem
|
||||
|
||||
`@AppStorage` is scattered across many views and managers, and `UserDefaults` changes trigger broad runtime work.
|
||||
|
||||
### Target Design
|
||||
|
||||
Introduce:
|
||||
|
||||
- `AppSettingsStore`
|
||||
Persistence boundary
|
||||
- `AppSettingsController`
|
||||
In-memory observable runtime settings
|
||||
|
||||
Suggested pattern:
|
||||
|
||||
```swift
|
||||
protocol AppSettingsStoreType {
|
||||
func load() -> AppSettings
|
||||
func save(_ settings: AppSettings)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppSettingsController: ObservableObject {
|
||||
@Published private(set) var settings: AppSettings
|
||||
|
||||
func update(_ update: (inout AppSettings) -> Void)
|
||||
}
|
||||
```
|
||||
|
||||
Views bind to `AppSettingsController`.
|
||||
Services subscribe to precise sub-slices of settings.
|
||||
|
||||
## Persistence Design
|
||||
|
||||
Persist only durable data:
|
||||
|
||||
- app settings
|
||||
- workspace summaries
|
||||
- screen-to-workspace assignments
|
||||
- optional workspace-local preferences
|
||||
- size presets
|
||||
|
||||
Do not persist:
|
||||
|
||||
- live `LocalProcess`
|
||||
- active shell buffer contents
|
||||
- transient hover/focus/transition state
|
||||
|
||||
Suggested storage keys:
|
||||
|
||||
- `appSettings`
|
||||
- `workspaceSummaries`
|
||||
- `screenAssignments`
|
||||
- `terminalSizePresets`
|
||||
|
||||
Prefer a single encoded settings object per concern over many independent keys.
|
||||
|
||||
## UI Structure
|
||||
|
||||
### Root Views
|
||||
|
||||
- `NotchRootView`
|
||||
- `ClosedNotchView`
|
||||
- `OpenWorkspaceView`
|
||||
- `TabStripView`
|
||||
- `WorkspacePickerView`
|
||||
- `SettingsRootView`
|
||||
|
||||
### View Rules
|
||||
|
||||
- Views may read state from observable objects.
|
||||
- Views may emit intents via closures or controller methods.
|
||||
- Views do not call global singletons.
|
||||
- Views do not own delayed business logic unless it is purely visual.
|
||||
|
||||
## Workspace UX Rules
|
||||
|
||||
- Every screen always has an assigned workspace.
|
||||
- A new screen defaults to:
|
||||
- the app default workspace strategy, or
|
||||
- the first existing workspace
|
||||
- Users can:
|
||||
- switch a screen to another workspace
|
||||
- create a new workspace from a screen
|
||||
- rename a workspace
|
||||
- Deleting a workspace:
|
||||
- reassigns affected screens to a fallback workspace
|
||||
- is disallowed if it is the last workspace
|
||||
|
||||
## Screen UX Rules
|
||||
|
||||
- A screen can be open/closed independently of other screens.
|
||||
- Two screens may point at the same workspace.
|
||||
- If a workspace is already open on another screen:
|
||||
- opening it on this screen should either transfer focus, or
|
||||
- close it on the other screen first
|
||||
|
||||
Recommended v1 behavior:
|
||||
|
||||
- one active open screen per workspace
|
||||
- opening workspace `W` on screen `B` closes `W` on screen `A`
|
||||
|
||||
That is simple, deterministic, and compatible with single `TerminalView` ownership.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 0: Hygiene
|
||||
|
||||
- remove dead settings and unused properties
|
||||
- fix version drift
|
||||
- fix stale comments
|
||||
- add test targets
|
||||
- isolate root build artifacts from source tree if desired
|
||||
|
||||
### Phase 1: Settings Layer
|
||||
|
||||
- create `AppSettings`, `AppSettingsStore`, `AppSettingsController`
|
||||
- move scattered `@AppStorage` usage into typed settings reads/writes
|
||||
- make `HotkeyService` observe only hotkey settings
|
||||
- make window sizing observe only relevant settings
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- no runtime logic depends on `UserDefaults.didChangeNotification`
|
||||
|
||||
### Phase 2: Workspace Core
|
||||
|
||||
- add `WorkspaceSummary`, `WorkspaceRegistry`, `WorkspaceController`
|
||||
- migrate current `TerminalManager` logic into `WorkspaceController`
|
||||
- ensure one default workspace exists
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- tabs exist inside a workspace object, not globally
|
||||
|
||||
### Phase 3: Screen Core
|
||||
|
||||
- add `ScreenContext`, `ScreenRegistry`
|
||||
- migrate current `NotchViewModel` responsibilities into `ScreenContext`
|
||||
- persist screen-to-workspace assignments
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- screen-local state is independent from workspace state
|
||||
|
||||
### Phase 4: Notch Lifecycle Orchestrator
|
||||
|
||||
- move hover/open/close/transition logic out of `ContentView`
|
||||
- add explicit transition states
|
||||
- centralize focus/open/close sequencing
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- `ContentView` becomes mostly rendering + view intents
|
||||
|
||||
### Phase 5: Window Coordination
|
||||
|
||||
- extract AppKit window creation and frame management into `WindowCoordinator`
|
||||
- keep `ScreenManager` as thin glue or replace it entirely
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- no window-specific behavior in workspace logic
|
||||
|
||||
### Phase 6: Workspace Switching UX
|
||||
|
||||
- add workspace picker UI
|
||||
- support create/switch/rename/delete workspaces
|
||||
- enforce one-open-screen-per-workspace behavior
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- a user can assign the same workspace to multiple screens
|
||||
|
||||
### Phase 7: Popout and Session Cleanup
|
||||
|
||||
- formalize detached tab ownership
|
||||
- remove session observer leaks
|
||||
- remove app-global current-directory mutation
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- session lifetime is explicit and testable
|
||||
|
||||
### Phase 8: Test Expansion
|
||||
|
||||
- add unit coverage for all core models/controllers
|
||||
- add integration tests for orchestrators
|
||||
- add XCUITests for key user flows
|
||||
|
||||
## Proposed File Layout
|
||||
|
||||
```text
|
||||
Downterm/CommandNotch/
|
||||
App/
|
||||
CommandNotchApp.swift
|
||||
AppDelegate.swift
|
||||
AppController.swift
|
||||
|
||||
Core/
|
||||
Settings/
|
||||
AppSettings.swift
|
||||
AppSettingsController.swift
|
||||
AppSettingsStore.swift
|
||||
UserDefaultsAppSettingsStore.swift
|
||||
Workspaces/
|
||||
WorkspaceSummary.swift
|
||||
WorkspaceState.swift
|
||||
WorkspaceRegistry.swift
|
||||
WorkspaceController.swift
|
||||
WorkspaceStore.swift
|
||||
Screens/
|
||||
ScreenContext.swift
|
||||
ScreenRegistry.swift
|
||||
ScreenAssignmentStore.swift
|
||||
Notch/
|
||||
NotchOrchestrator.swift
|
||||
NotchStateMachine.swift
|
||||
NotchFrameCalculator.swift
|
||||
Terminal/
|
||||
TerminalSession.swift
|
||||
TerminalSessionFactory.swift
|
||||
TerminalTheme.swift
|
||||
TerminalTabState.swift
|
||||
Hotkeys/
|
||||
HotkeyService.swift
|
||||
HotkeyBinding.swift
|
||||
AppCommand.swift
|
||||
|
||||
UI/
|
||||
Notch/
|
||||
NotchRootView.swift
|
||||
ClosedNotchView.swift
|
||||
OpenWorkspaceView.swift
|
||||
TabStripView.swift
|
||||
WorkspacePickerView.swift
|
||||
Settings/
|
||||
SettingsRootView.swift
|
||||
GeneralSettingsView.swift
|
||||
AppearanceSettingsView.swift
|
||||
AnimationSettingsView.swift
|
||||
TerminalSettingsView.swift
|
||||
HotkeySettingsView.swift
|
||||
WorkspaceSettingsView.swift
|
||||
Components/
|
||||
SwiftTermView.swift
|
||||
HotkeyRecorderView.swift
|
||||
NotchShape.swift
|
||||
|
||||
AppKit/
|
||||
WindowCoordinator.swift
|
||||
NotchWindow.swift
|
||||
SettingsCoordinator.swift
|
||||
PopoutCoordinator.swift
|
||||
ScreenProvider.swift
|
||||
|
||||
Persistence/
|
||||
CodableStore.swift
|
||||
|
||||
Extensions/
|
||||
NSScreen+Extensions.swift
|
||||
```
|
||||
|
||||
## Protocol Boundaries Required For Tests
|
||||
|
||||
Create protocols around:
|
||||
|
||||
- settings store
|
||||
- workspace store
|
||||
- screen provider
|
||||
- window factory
|
||||
- hotkey registrar
|
||||
- launch-at-login service
|
||||
- terminal session factory
|
||||
- clock/scheduler for delayed hover/open/close logic
|
||||
|
||||
Examples:
|
||||
|
||||
```swift
|
||||
protocol ScreenProviderType {
|
||||
var screens: [ScreenDescriptor] { get }
|
||||
}
|
||||
|
||||
protocol WindowCoordinatorType {
|
||||
func showWindow(for screenID: ScreenID)
|
||||
func hideWindow(for screenID: ScreenID)
|
||||
func updateFrame(for screenID: ScreenID, frame: CGRect)
|
||||
func focusTerminal(for screenID: ScreenID)
|
||||
}
|
||||
|
||||
protocol SchedulerType {
|
||||
func schedule(after interval: TimeInterval, _ action: @escaping () -> Void) -> Cancellable
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
## 1. Unit Tests
|
||||
|
||||
Add a `CommandNotchTests` target.
|
||||
|
||||
Focus on pure logic:
|
||||
|
||||
- `WorkspaceRegistryTests`
|
||||
- creates default workspace
|
||||
- cannot delete last workspace
|
||||
- rename/delete/create behavior
|
||||
- `WorkspaceControllerTests`
|
||||
- new tab
|
||||
- close active tab
|
||||
- detach active tab
|
||||
- active tab index/id updates correctly
|
||||
- `ScreenRegistryTests`
|
||||
- new screen gets valid assignment
|
||||
- assignment changes persist
|
||||
- missing workspace reassigns to fallback
|
||||
- `NotchStateMachineTests`
|
||||
- closed -> opening -> open
|
||||
- close suppression while hovering
|
||||
- resizing transitions
|
||||
- `NotchFrameCalculatorTests`
|
||||
- clamping
|
||||
- per-screen max bounds
|
||||
- closed-notch size behavior
|
||||
- `HotkeyBindingTests`
|
||||
- event matching
|
||||
- preset digit mapping
|
||||
- `AppSettingsControllerTests`
|
||||
- only targeted changes publish
|
||||
|
||||
## 2. Integration Tests
|
||||
|
||||
Use fakes for AppKit boundaries.
|
||||
|
||||
- `NotchOrchestratorIntegrationTests`
|
||||
- opening a workspace on one screen closes it on another screen if shared
|
||||
- focus transfer rules
|
||||
- preset resize behavior
|
||||
- `AppControllerIntegrationTests`
|
||||
- hotkey command routes to active screen/workspace
|
||||
- settings changes update workspaces without broad side effects
|
||||
|
||||
## 3. UI Tests
|
||||
|
||||
Add a `CommandNotchUITests` target with XCUITest.
|
||||
|
||||
Recommended initial flows:
|
||||
|
||||
- launch app and open settings
|
||||
- create workspace from settings or notch UI
|
||||
- assign workspace to another screen context if testable
|
||||
- create tab
|
||||
- switch tabs
|
||||
- apply size preset
|
||||
- detach active tab
|
||||
|
||||
UI tests should validate behavior and existence, not exact pixel values.
|
||||
|
||||
## 4. Optional Snapshot Tests
|
||||
|
||||
Only add after state is injectable.
|
||||
|
||||
Snapshot candidates:
|
||||
|
||||
- closed notch
|
||||
- open notch with one tab
|
||||
- workspace picker
|
||||
- settings panes
|
||||
|
||||
Do not start here.
|
||||
|
||||
## Testing SwiftUI/AppKit UI
|
||||
|
||||
### What to test directly
|
||||
|
||||
- view model/controller behavior
|
||||
- user intents emitted by the view
|
||||
- XCUITest end-to-end flows
|
||||
|
||||
### What not to over-invest in early
|
||||
|
||||
- exact animation curves
|
||||
- fragile layout pixel assertions
|
||||
- direct testing of `NSWindow` internals unless wrapped behind protocols
|
||||
|
||||
### Practical answer
|
||||
|
||||
For this app, “testing UI” mostly means testing the logic that drives the UI, plus a thin XCUITest layer that proves the main flows work.
|
||||
|
||||
## Current Issues Mapped To Fixes
|
||||
|
||||
- Global singleton tab state
|
||||
Fix with `WorkspaceRegistry` + `WorkspaceController`
|
||||
- `UserDefaults` as runtime bus
|
||||
Fix with typed settings controller and targeted subscriptions
|
||||
- Dead gesture settings
|
||||
Remove or implement later behind a dedicated feature
|
||||
- Observer leakage in tab creation
|
||||
Store cancellables per session or derive titles from workspace state
|
||||
- App-global cwd mutation
|
||||
Remove from session startup; set process cwd explicitly if supported
|
||||
- Monolithic settings file
|
||||
Split into feature files bound to typed settings
|
||||
- Timing-based UI behavior spread across layers
|
||||
Centralize in `NotchOrchestrator`
|
||||
- No tests
|
||||
Add unit + integration + XCUITest targets
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Add `CommandNotchTests`
|
||||
- [ ] Add `CommandNotchUITests`
|
||||
- [ ] Create `AppSettings` model
|
||||
- [ ] Create `AppSettingsStore`
|
||||
- [ ] Replace broad defaults observation
|
||||
- [ ] Create `WorkspaceRegistry`
|
||||
- [ ] Create `WorkspaceController`
|
||||
- [ ] Move tab logic out of `TerminalManager`
|
||||
- [ ] Create `ScreenContext`
|
||||
- [ ] Create `ScreenRegistry`
|
||||
- [ ] Persist screen assignments
|
||||
- [ ] Create `NotchOrchestrator`
|
||||
- [ ] Create `NotchFrameCalculator`
|
||||
- [ ] Extract `WindowCoordinator`
|
||||
- [ ] Add workspace picker UI
|
||||
- [ ] Add create/switch/rename/delete workspace flows
|
||||
- [ ] Enforce one-open-screen-per-workspace
|
||||
- [ ] Clean up detached session ownership
|
||||
- [ ] Split settings views into separate files
|
||||
- [ ] Remove dead settings and unused code
|
||||
- [ ] Add unit tests for registry/controller/state machine/frame calculator
|
||||
- [ ] Add initial XCUITest flows
|
||||
|
||||
## First Concrete Refactor Slice
|
||||
|
||||
If implementing incrementally, the best first slice is:
|
||||
|
||||
1. add tests target
|
||||
2. add `AppSettings`
|
||||
3. add `WorkspaceRegistry` with a single default workspace
|
||||
4. migrate current `TerminalManager` into a first `WorkspaceController`
|
||||
5. change the app so all current behavior still uses one workspace
|
||||
|
||||
That gives immediate architectural improvement without changing UX yet.
|
||||
|
||||
## Definition Of Done
|
||||
|
||||
The refactor is successful when:
|
||||
|
||||
- there is no critical runtime logic driven by broad `UserDefaults` notifications
|
||||
- tabs/sessions live under workspaces, not globally
|
||||
- screens are explicitly assigned to workspaces
|
||||
- the same workspace can be selected on multiple screens
|
||||
- only one open/focused screen owns a workspace’s live terminal at a time
|
||||
- window behavior is separated from domain logic
|
||||
- unit tests cover the core state machines and registries
|
||||
- XCUITests cover main user flows
|
||||
|
||||
Reference in New Issue
Block a user