diff --git a/Downterm/CommandNotch.xcodeproj/project.pbxproj b/Downterm/CommandNotch.xcodeproj/project.pbxproj index 5e5493a..7fe8ece 100644 --- a/Downterm/CommandNotch.xcodeproj/project.pbxproj +++ b/Downterm/CommandNotch.xcodeproj/project.pbxproj @@ -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 = ""; }; 0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; 0B567F3B5D006D2B35630CFF /* LaunchAtLoginHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginHelper.swift; sourceTree = ""; }; 0F4A88A33D93B6E100A1C002 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A20000000000000000000001 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + A20000000000000000000002 /* AppSettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsController.swift; sourceTree = ""; }; + A20000000000000000000003 /* AppSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStore.swift; sourceTree = ""; }; + A20000000000000000000004 /* WorkspaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceController.swift; sourceTree = ""; }; + A20000000000000000000005 /* WorkspaceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistry.swift; sourceTree = ""; }; + A20000000000000000000006 /* WorkspaceSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSummary.swift; sourceTree = ""; }; + 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 = ""; }; + A20000000000000000000009 /* WorkspaceRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistryTests.swift; sourceTree = ""; }; + 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 = ""; }; + A2000000000000000000000C /* ScreenRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenRegistryTests.swift; sourceTree = ""; }; + A2000000000000000000000D /* ScreenRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenRegistry.swift; sourceTree = ""; }; + A2000000000000000000000E /* WorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStore.swift; sourceTree = ""; }; + A2000000000000000000000F /* WorkspaceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStoreTests.swift; sourceTree = ""; }; + A20000000000000000000010 /* NotchOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestrator.swift; sourceTree = ""; }; + A20000000000000000000011 /* NotchOrchestratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestratorTests.swift; sourceTree = ""; }; + A20000000000000000000012 /* ScreenContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenContextTests.swift; sourceTree = ""; }; + A20000000000000000000013 /* WindowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCoordinator.swift; sourceTree = ""; }; + A20000000000000000000014 /* WindowFrameCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrameCalculatorTests.swift; sourceTree = ""; }; + A20000000000000000000015 /* SettingsBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBindings.swift; sourceTree = ""; }; + A20000000000000000000016 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; + A20000000000000000000017 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; + A20000000000000000000018 /* AnimationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationSettingsView.swift; sourceTree = ""; }; + A20000000000000000000019 /* TerminalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsView.swift; sourceTree = ""; }; + A2000000000000000000001A /* HotkeySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeySettingsView.swift; sourceTree = ""; }; + A2000000000000000000001B /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = ""; }; + A2000000000000000000001C /* AppSettingsControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsControllerTests.swift; sourceTree = ""; }; + A2000000000000000000001D /* WorkspaceSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSwitcherView.swift; sourceTree = ""; }; + A2000000000000000000001E /* WorkspacesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacesSettingsView.swift; sourceTree = ""; }; + A2000000000000000000001F /* CommandNotchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchUITests.swift; sourceTree = ""; }; + 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 = ""; }; 1E47000112562615C7E59489 /* SwiftTermView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTermView.swift; sourceTree = ""; }; 1FC09C538CBE7C2D072008B2 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = ""; }; @@ -47,7 +126,6 @@ 490C53139360D970099D8F3D /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = ""; }; 4B671125208055E5334CB85E /* CommandNotchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchApp.swift; sourceTree = ""; }; 4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyBinding.swift; sourceTree = ""; }; - 589421631401C819FE1A7BA9 /* NotchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchViewModel.swift; sourceTree = ""; }; 5C0779490DE9020FBBC464BE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 665CFC051CF185B71199608D /* 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 = ""; }; @@ -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 = ""; @@ -98,20 +193,54 @@ isa = PBXGroup; children = ( 665CFC051CF185B71199608D /* CommandNotch.app */, + A20000000000000000000007 /* CommandNotchTests.xctest */, + A20000000000000000000020 /* CommandNotchUITests.xctest */, ); name = Products; sourceTree = ""; }; + 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 = ""; + }; + A30000000000000000000002 /* CommandNotchUITests */ = { + isa = PBXGroup; + children = ( + A2000000000000000000001F /* CommandNotchUITests.swift */, + ); + path = CommandNotchUITests; + sourceTree = ""; + }; 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 = ""; @@ -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 = ""; @@ -164,6 +302,8 @@ isa = PBXGroup; children = ( 9E1CA4816F67033BBD52D8A3 /* CommandNotch */, + A30000000000000000000001 /* CommandNotchTests */, + A30000000000000000000002 /* CommandNotchUITests */, 792DD4F8C079680683D8FF7A /* Products */, ); sourceTree = ""; @@ -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 = ( diff --git a/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate b/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate index f499c48..6e7d28c 100644 Binary files a/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate and b/Downterm/CommandNotch.xcodeproj/project.xcworkspace/xcuserdata/harvmaster.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Downterm/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist b/Downterm/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist index e6d8579..aed665a 100644 --- a/Downterm/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Downterm/CommandNotch.xcodeproj/xcuserdata/harvmaster.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,16 @@ orderHint 0 + CommandNotchTests.xcscheme_^#shared#^_ + + orderHint + 2 + + CommandNotchUITests.xcscheme_^#shared#^_ + + orderHint + 2 + Release-CommandNotch.xcscheme_^#shared#^_ orderHint diff --git a/Downterm/CommandNotch/AppDelegate.swift b/Downterm/CommandNotch/AppDelegate.swift index fd6f4e6..423e855 100644 --- a/Downterm/CommandNotch/AppDelegate.swift +++ b/Downterm/CommandNotch/AppDelegate.swift @@ -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() + private let settingsController = AppSettingsController.shared func applicationDidFinishLaunching(_ notification: Notification) { NotchSettings.registerDefaults() - NSApp.setActivationPolicy(.accessory) + + 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) } -} -// MARK: - KVO key paths - -private extension UserDefaults { - @objc var terminalFontSize: Double { - double(forKey: NotchSettings.Keys.terminalFontSize) + private var launchArguments: [String] { + ProcessInfo.processInfo.arguments } - @objc var terminalTheme: String { - string(forKey: NotchSettings.Keys.terminalTheme) ?? NotchSettings.Defaults.terminalTheme + private var isRunningUITests: Bool { + launchArguments.contains(UITestLaunchArgument.regularActivation) + || launchArguments.contains(UITestLaunchArgument.showSettings) + || launchArguments.contains(UITestLaunchArgument.openNotch) } - @objc var showOnAllDisplays: Bool { - bool(forKey: NotchSettings.Keys.showOnAllDisplays) + private func applyUITestLaunchBehaviorIfNeeded() { + guard isRunningUITests else { return } + + DispatchQueue.main.async { @MainActor in + if self.launchArguments.contains(UITestLaunchArgument.showSettings) { + SettingsWindowController.shared.showSettings() + } + + if self.launchArguments.contains(UITestLaunchArgument.openNotch), + let screenID = ScreenRegistry.shared.activeScreenID() { + ScreenManager.shared.openNotch(screenID: screenID) + } + } } } diff --git a/Downterm/CommandNotch/CommandNotchApp.swift b/Downterm/CommandNotch/CommandNotchApp.swift index d971d0b..924362f 100644 --- a/Downterm/CommandNotch/CommandNotchApp.swift +++ b/Downterm/CommandNotch/CommandNotchApp.swift @@ -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() } diff --git a/Downterm/CommandNotch/Components/TabBar.swift b/Downterm/CommandNotch/Components/TabBar.swift index 88953f6..67798a1 100644 --- a/Downterm/CommandNotch/Components/TabBar.swift +++ b/Downterm/CommandNotch/Components/TabBar.swift @@ -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) } } diff --git a/Downterm/CommandNotch/ContentView.swift b/Downterm/CommandNotch/ContentView.swift index 9ab597e..d1731dd 100644 --- a/Downterm/CommandNotch/ContentView.swift +++ b/Downterm/CommandNotch/ContentView.swift @@ -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? @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 - .transition(.opacity) - } else { - closedContent + WorkspaceScopedView(screen: screen, screenRegistry: screenRegistry) { workspace in + if screen.notchState == .open { + openContent(workspace: workspace) + .transition(.opacity) + } else { + 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: 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: View { + @ObservedObject var workspace: WorkspaceController + let content: (WorkspaceController) -> Content + + var body: some View { + content(workspace) + } +} diff --git a/Downterm/CommandNotch/Extensions/NSScreen+Extensions.swift b/Downterm/CommandNotch/Extensions/NSScreen+Extensions.swift index a7acd92..8f12c37 100644 --- a/Downterm/CommandNotch/Extensions/NSScreen+Extensions.swift +++ b/Downterm/CommandNotch/Extensions/NSScreen+Extensions.swift @@ -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 } } } diff --git a/Downterm/CommandNotch/Managers/HotkeyManager.swift b/Downterm/CommandNotch/Managers/HotkeyManager.swift index 2c9075d..daee887 100644 --- a/Downterm/CommandNotch/Managers/HotkeyManager.swift +++ b/Downterm/CommandNotch/Managers/HotkeyManager.swift @@ -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,15 +160,17 @@ 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 - self?.registerToggleHotkey() - } + guard let settingsProvider = settingsProvider as? AppSettingsController else { return } + + settingsCancellable = settingsProvider.$settings + .map(\.hotkeys.toggle) + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in + self?.registerToggleHotkey() + } } // MARK: - Local monitor (tab-level hotkeys, only when our app is active) diff --git a/Downterm/CommandNotch/Managers/ScreenManager.swift b/Downterm/CommandNotch/Managers/ScreenManager.swift index 4abb6f6..2c171f4 100644 --- a/Downterm/CommandNotch/Managers/ScreenManager.swift +++ b/Downterm/CommandNotch/Managers/ScreenManager.swift @@ -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() 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() - } - - 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 openNotch(screenID: ScreenID) { + orchestrator.open(screenID: screenID) } - 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 + } - // Close the notch when the window loses focus - window.onResignKey = { [weak self] in - self?.closeNotch(screenUUID: uuid) + self.windowCoordinator.updateWindowFrame( + for: screenID, + context: context, + centerHorizontally: true + ) } + context.requestTerminalFocus = { [weak self] in + guard let self else { return } - // 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) + self.windowCoordinator.focusActiveTerminal(for: screenID) { [weak self] in + self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView + } } let hostingView = NSHostingView( - rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared) - .preferredColorScheme(.dark) + 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) + for context in screenRegistry.allScreens() { + context.refreshClosedSize() + windowCoordinator.repositionWindow( + for: context.id, + context: context, + 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 - ) - } - - 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 } - - DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in - self?.focusActiveTerminal(in: screenUUID, attemptsRemaining: attemptsRemaining - 1) + windowCoordinator.presentOpen(for: screenID) { [weak self] in + self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView } } + + func performClosePresentation(for screenID: ScreenID) { + guard screenRegistry.screenContext(for: screenID) != nil else { + return + } + + windowCoordinator.presentClose(for: screenID) + } } diff --git a/Downterm/CommandNotch/Managers/SettingsWindowController.swift b/Downterm/CommandNotch/Managers/SettingsWindowController.swift index d3cac39..97633ce 100644 --- a/Downterm/CommandNotch/Managers/SettingsWindowController.swift +++ b/Downterm/CommandNotch/Managers/SettingsWindowController.swift @@ -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 diff --git a/Downterm/CommandNotch/Managers/WindowCoordinator.swift b/Downterm/CommandNotch/Managers/WindowCoordinator.swift new file mode 100644 index 0000000..21a84fa --- /dev/null +++ b/Downterm/CommandNotch/Managers/WindowCoordinator.swift @@ -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 { + 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)) + } +} diff --git a/Downterm/CommandNotch/Models/AppSettings.swift b/Downterm/CommandNotch/Models/AppSettings.swift new file mode 100644 index 0000000..f13b730 --- /dev/null +++ b/Downterm/CommandNotch/Models/AppSettings.swift @@ -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 } +} diff --git a/Downterm/CommandNotch/Models/AppSettingsController.swift b/Downterm/CommandNotch/Models/AppSettingsController.swift new file mode 100644 index 0000000..0db8e3a --- /dev/null +++ b/Downterm/CommandNotch/Models/AppSettingsController.swift @@ -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) + } +} diff --git a/Downterm/CommandNotch/Models/AppSettingsStore.swift b/Downterm/CommandNotch/Models/AppSettingsStore.swift new file mode 100644 index 0000000..855d6c9 --- /dev/null +++ b/Downterm/CommandNotch/Models/AppSettingsStore.swift @@ -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 + } +} diff --git a/Downterm/CommandNotch/Models/NotchOrchestrator.swift b/Downterm/CommandNotch/Models/NotchOrchestrator.swift new file mode 100644 index 0000000..1e778e8 --- /dev/null +++ b/Downterm/CommandNotch/Models/NotchOrchestrator.swift @@ -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 + } +} diff --git a/Downterm/CommandNotch/Models/NotchSettings.swift b/Downterm/CommandNotch/Models/NotchSettings.swift index 796cb8a..e692097 100644 --- a/Downterm/CommandNotch/Models/NotchSettings.swift +++ b/Downterm/CommandNotch/Models/NotchSettings.swift @@ -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 + } } diff --git a/Downterm/CommandNotch/Models/NotchViewModel.swift b/Downterm/CommandNotch/Models/NotchViewModel.swift deleted file mode 100644 index e3a3632..0000000 --- a/Downterm/CommandNotch/Models/NotchViewModel.swift +++ /dev/null @@ -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() - - @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? - - 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 { - Swift.min(Swift.max(self, range.lowerBound), range.upperBound) - } -} diff --git a/Downterm/CommandNotch/Models/ScreenContext.swift b/Downterm/CommandNotch/Models/ScreenContext.swift new file mode 100644 index 0000000..5840261 --- /dev/null +++ b/Downterm/CommandNotch/Models/ScreenContext.swift @@ -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 { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/Downterm/CommandNotch/Models/ScreenRegistry.swift b/Downterm/CommandNotch/Models/ScreenRegistry.swift new file mode 100644 index 0000000..9b38fb8 --- /dev/null +++ b/Downterm/CommandNotch/Models/ScreenRegistry.swift @@ -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() + + 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, + 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 {} diff --git a/Downterm/CommandNotch/Models/TerminalManager.swift b/Downterm/CommandNotch/Models/TerminalManager.swift index 6d32dbe..a45dccb 100644 --- a/Downterm/CommandNotch/Models/TerminalManager.swift +++ b/Downterm/CommandNotch/Models/TerminalManager.swift @@ -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() + 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) } } diff --git a/Downterm/CommandNotch/Models/TerminalSession.swift b/Downterm/CommandNotch/Models/TerminalSession.swift index edbf98f..f75f651 100644 --- a/Downterm/CommandNotch/Models/TerminalSession.swift +++ b/Downterm/CommandNotch/Models/TerminalSession.swift @@ -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 } diff --git a/Downterm/CommandNotch/Models/WorkspaceController.swift b/Downterm/CommandNotch/Models/WorkspaceController.swift new file mode 100644 index 0000000..db7500f --- /dev/null +++ b/Downterm/CommandNotch/Models/WorkspaceController.swift @@ -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) + } + } +} diff --git a/Downterm/CommandNotch/Models/WorkspaceRegistry.swift b/Downterm/CommandNotch/Models/WorkspaceRegistry.swift new file mode 100644 index 0000000..7dbcec4 --- /dev/null +++ b/Downterm/CommandNotch/Models/WorkspaceRegistry.swift @@ -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) + } +} diff --git a/Downterm/CommandNotch/Models/WorkspaceStore.swift b/Downterm/CommandNotch/Models/WorkspaceStore.swift new file mode 100644 index 0000000..2e1ac2f --- /dev/null +++ b/Downterm/CommandNotch/Models/WorkspaceStore.swift @@ -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) + } +} diff --git a/Downterm/CommandNotch/Models/WorkspaceSummary.swift b/Downterm/CommandNotch/Models/WorkspaceSummary.swift new file mode 100644 index 0000000..1ad8593 --- /dev/null +++ b/Downterm/CommandNotch/Models/WorkspaceSummary.swift @@ -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? +} diff --git a/Downterm/CommandNotch/Views/AboutSettingsView.swift b/Downterm/CommandNotch/Views/AboutSettingsView.swift new file mode 100644 index 0000000..2ca4ed0 --- /dev/null +++ b/Downterm/CommandNotch/Views/AboutSettingsView.swift @@ -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) + } +} diff --git a/Downterm/CommandNotch/Views/AnimationSettingsView.swift b/Downterm/CommandNotch/Views/AnimationSettingsView.swift new file mode 100644 index 0000000..4c3f55f --- /dev/null +++ b/Downterm/CommandNotch/Views/AnimationSettingsView.swift @@ -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, damping: Binding) -> some View { + HStack { + Text("Response") + Slider(value: response, in: 0.1...1.5, step: 0.01) + Text(String(format: "%.2f", response.wrappedValue)) + .monospacedDigit() + .frame(width: 50) + } + HStack { + Text("Damping") + Slider(value: damping, in: 0.1...1.5, step: 0.01) + Text(String(format: "%.2f", damping.wrappedValue)) + .monospacedDigit() + .frame(width: 50) + } + } + + @ViewBuilder + private func durationControl(duration: Binding) -> 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) + } + } +} diff --git a/Downterm/CommandNotch/Views/AppearanceSettingsView.swift b/Downterm/CommandNotch/Views/AppearanceSettingsView.swift new file mode 100644 index 0000000..6f6f80c --- /dev/null +++ b/Downterm/CommandNotch/Views/AppearanceSettingsView.swift @@ -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) + } +} diff --git a/Downterm/CommandNotch/Views/GeneralSettingsView.swift b/Downterm/CommandNotch/Views/GeneralSettingsView.swift new file mode 100644 index 0000000..a0b5fdd --- /dev/null +++ b/Downterm/CommandNotch/Views/GeneralSettingsView.swift @@ -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) + } +} diff --git a/Downterm/CommandNotch/Views/HotkeySettingsView.swift b/Downterm/CommandNotch/Views/HotkeySettingsView.swift new file mode 100644 index 0000000..71a92f9 --- /dev/null +++ b/Downterm/CommandNotch/Views/HotkeySettingsView.swift @@ -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) + } +} diff --git a/Downterm/CommandNotch/Views/SettingsBindings.swift b/Downterm/CommandNotch/Views/SettingsBindings.swift new file mode 100644 index 0000000..5146a86 --- /dev/null +++ b/Downterm/CommandNotch/Views/SettingsBindings.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@MainActor +extension AppSettingsController { + func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { self.settings[keyPath: keyPath] }, + set: { newValue in + self.update { $0[keyPath: keyPath] = newValue } + } + ) + } +} diff --git a/Downterm/CommandNotch/Views/SettingsView.swift b/Downterm/CommandNotch/Views/SettingsView.swift index e55e655..baf75dc 100644 --- a/Downterm/CommandNotch/Views/SettingsView.swift +++ b/Downterm/CommandNotch/Views/SettingsView.swift @@ -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, damping: Binding) -> some View { - HStack { - Text("Response") - Slider(value: response, in: 0.1...1.5, step: 0.01) - Text(String(format: "%.2f", response.wrappedValue)).monospacedDigit().frame(width: 50) - } - HStack { - Text("Damping") - Slider(value: damping, in: 0.1...1.5, step: 0.01) - Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50) - } - } - - @ViewBuilder - private func durationControl(duration: Binding) -> 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, key: String) -> Binding { - Binding( - get: { state.wrappedValue }, - set: { newValue in - state.wrappedValue = newValue - save(newValue, key: key) - } - ) - } - - private func save(_ binding: HotkeyBinding, key: String) { - UserDefaults.standard.set(binding.toJSON(), forKey: key) - } - - private static func loadBinding(_ key: String, fallback: HotkeyBinding) -> HotkeyBinding { - guard let json = UserDefaults.standard.string(forKey: key), - let b = HotkeyBinding.fromJSON(json) else { return fallback } - return b - } -} - -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) - } -} diff --git a/Downterm/CommandNotch/Views/TerminalSettingsView.swift b/Downterm/CommandNotch/Views/TerminalSettingsView.swift new file mode 100644 index 0000000..1f60fd6 --- /dev/null +++ b/Downterm/CommandNotch/Views/TerminalSettingsView.swift @@ -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) + } +} diff --git a/Downterm/CommandNotch/Views/WorkspaceSwitcherView.swift b/Downterm/CommandNotch/Views/WorkspaceSwitcherView.swift new file mode 100644 index 0000000..98d7a03 --- /dev/null +++ b/Downterm/CommandNotch/Views/WorkspaceSwitcherView.swift @@ -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) + } +} diff --git a/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift b/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift new file mode 100644 index 0000000..39795ab --- /dev/null +++ b/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift @@ -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 ?? "" + } +} diff --git a/Downterm/CommandNotchTests/AppSettingsControllerTests.swift b/Downterm/CommandNotchTests/AppSettingsControllerTests.swift new file mode 100644 index 0000000..a104004 --- /dev/null +++ b/Downterm/CommandNotchTests/AppSettingsControllerTests.swift @@ -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 + } +} diff --git a/Downterm/CommandNotchTests/AppSettingsStoreTests.swift b/Downterm/CommandNotchTests/AppSettingsStoreTests.swift new file mode 100644 index 0000000..4fcb853 --- /dev/null +++ b/Downterm/CommandNotchTests/AppSettingsStoreTests.swift @@ -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) + } +} diff --git a/Downterm/CommandNotchTests/NotchOrchestratorTests.swift b/Downterm/CommandNotchTests/NotchOrchestratorTests.swift new file mode 100644 index 0000000..5af5ff4 --- /dev/null +++ b/Downterm/CommandNotchTests/NotchOrchestratorTests.swift @@ -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 + } +} diff --git a/Downterm/CommandNotchTests/ScreenContextTests.swift b/Downterm/CommandNotchTests/ScreenContextTests.swift new file mode 100644 index 0000000..4af63af --- /dev/null +++ b/Downterm/CommandNotchTests/ScreenContextTests.swift @@ -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 + } +} diff --git a/Downterm/CommandNotchTests/ScreenRegistryTests.swift b/Downterm/CommandNotchTests/ScreenRegistryTests.swift new file mode 100644 index 0000000..b81de00 --- /dev/null +++ b/Downterm/CommandNotchTests/ScreenRegistryTests.swift @@ -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.") + } +} diff --git a/Downterm/CommandNotchTests/WindowFrameCalculatorTests.swift b/Downterm/CommandNotchTests/WindowFrameCalculatorTests.swift new file mode 100644 index 0000000..b754595 --- /dev/null +++ b/Downterm/CommandNotchTests/WindowFrameCalculatorTests.swift @@ -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) + } +} diff --git a/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift b/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift new file mode 100644 index 0000000..49de141 --- /dev/null +++ b/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift @@ -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.") + } +} diff --git a/Downterm/CommandNotchTests/WorkspaceStoreTests.swift b/Downterm/CommandNotchTests/WorkspaceStoreTests.swift new file mode 100644 index 0000000..578388a --- /dev/null +++ b/Downterm/CommandNotchTests/WorkspaceStoreTests.swift @@ -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) + } +} diff --git a/Downterm/CommandNotchUITests/CommandNotchUITests.swift b/Downterm/CommandNotchUITests/CommandNotchUITests.swift new file mode 100644 index 0000000..3afa835 --- /dev/null +++ b/Downterm/CommandNotchUITests/CommandNotchUITests.swift @@ -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 + } +} diff --git a/Downterm/project.yml b/Downterm/project.yml index de57634..0546e40 100644 --- a/Downterm/project.yml +++ b/Downterm/project.yml @@ -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 diff --git a/docs/workspace-architecture-spec.md b/docs/workspace-architecture-spec.md new file mode 100644 index 0000000..0061eab --- /dev/null +++ b/docs/workspace-architecture-spec.md @@ -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 +