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 6e7d28c..0087c70 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/Components/HotkeyRecorderView.swift b/Downterm/CommandNotch/Components/HotkeyRecorderView.swift index 50bdcc6..9d30be9 100644 --- a/Downterm/CommandNotch/Components/HotkeyRecorderView.swift +++ b/Downterm/CommandNotch/Components/HotkeyRecorderView.swift @@ -76,9 +76,7 @@ struct HotkeyRecorderField: NSViewRepresentable { } func updateNSView(_ nsView: HotkeyNSView, context: Context) { - nsView.currentLabel = binding.displayString - nsView.showRecording = isRecording - nsView.needsDisplay = true + nsView.update(currentLabel: binding.displayString, isRecording: isRecording) } } @@ -99,9 +97,7 @@ struct OptionalHotkeyRecorderField: NSViewRepresentable { } func updateNSView(_ nsView: HotkeyNSView, context: Context) { - nsView.currentLabel = binding?.displayString ?? "Not set" - nsView.showRecording = isRecording - nsView.needsDisplay = true + nsView.update(currentLabel: binding?.displayString ?? "Not set", isRecording: isRecording) } } @@ -183,6 +179,12 @@ class HotkeyNSView: NSView { updateLabelAppearance() } + func update(currentLabel: String, isRecording: Bool) { + self.currentLabel = currentLabel + showRecording = isRecording + updateLabelAppearance() + } + private func updateLabelAppearance() { label.stringValue = showRecording ? "Press keys..." : currentLabel label.textColor = showRecording ? .controlAccentColor : .labelColor diff --git a/Downterm/CommandNotch/Components/TabBar.swift b/Downterm/CommandNotch/Components/TabBar.swift index 67798a1..74c6902 100644 --- a/Downterm/CommandNotch/Components/TabBar.swift +++ b/Downterm/CommandNotch/Components/TabBar.swift @@ -9,17 +9,6 @@ struct TabBar: View { var body: some View { HStack(spacing: 0) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 2) { - ForEach(Array(workspace.tabs.enumerated()), id: \.element.id) { index, tab in - tabButton(for: tab, at: index) - } - } - .padding(.horizontal, 4) - } - - Spacer() - Button { workspace.newTab() } label: { @@ -31,6 +20,15 @@ struct TabBar: View { .accessibilityIdentifier("notch.new-tab") .buttonStyle(.plain) .padding(.horizontal, 8) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(Array(workspace.tabs.enumerated()), id: \.element.id) { index, tab in + tabButton(for: tab, at: index) + } + } + .padding(.horizontal, 4) + } } .frame(height: 28) .background(.black) diff --git a/Downterm/CommandNotch/Managers/HotkeyManager.swift b/Downterm/CommandNotch/Managers/HotkeyManager.swift index daee887..6d758b3 100644 --- a/Downterm/CommandNotch/Managers/HotkeyManager.swift +++ b/Downterm/CommandNotch/Managers/HotkeyManager.swift @@ -5,7 +5,7 @@ import Combine /// Manages global and local hotkeys. /// /// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works -/// system-wide without Accessibility permission. Tab-level hotkeys +/// system-wide without Accessibility permission. Notch-scoped hotkeys /// use a local `NSEvent` monitor (only fires when our app is active). @MainActor class HotkeyManager { @@ -19,21 +19,29 @@ class HotkeyManager { var onCloseTab: (() -> Void)? var onNextTab: (() -> Void)? var onPreviousTab: (() -> Void)? + var onNextWorkspace: (() -> Void)? + var onPreviousWorkspace: (() -> Void)? var onDetachTab: (() -> Void)? var onApplySizePreset: ((TerminalSizePreset) -> Void)? var onSwitchToTab: ((Int) -> Void)? + var onSwitchToWorkspace: ((WorkspaceID) -> Void)? - /// Tab-level hotkeys only fire when the notch is open. + /// Notch-scoped hotkeys only fire when the notch is open. var isNotchOpen: Bool = false private var hotKeyRef: EventHotKeyRef? private var eventHandlerRef: EventHandlerRef? private var localMonitor: Any? private let settingsProvider: TerminalSessionConfigurationProviding + private let workspaceRegistry: WorkspaceRegistry private var settingsCancellable: AnyCancellable? - init(settingsProvider: TerminalSessionConfigurationProviding? = nil) { + init( + settingsProvider: TerminalSessionConfigurationProviding? = nil, + workspaceRegistry: WorkspaceRegistry? = nil + ) { self.settingsProvider = settingsProvider ?? AppSettingsController.shared + self.workspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared } // MARK: - Resolved bindings from typed runtime settings @@ -53,6 +61,12 @@ class HotkeyManager { private var prevTabBinding: HotkeyBinding { settingsProvider.hotkeySettings.previousTab } + private var nextWorkspaceBinding: HotkeyBinding { + settingsProvider.hotkeySettings.nextWorkspace + } + private var previousWorkspaceBinding: HotkeyBinding { + settingsProvider.hotkeySettings.previousWorkspace + } private var detachBinding: HotkeyBinding { settingsProvider.hotkeySettings.detachTab } @@ -173,7 +187,7 @@ class HotkeyManager { } } - // MARK: - Local monitor (tab-level hotkeys, only when our app is active) + // MARK: - Local monitor (notch-level hotkeys, only when our app is active) private func installLocalMonitor() { localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in @@ -189,9 +203,9 @@ class HotkeyManager { } } - /// Handles tab-level hotkeys. Returns true if the event was consumed. + /// Handles notch-scoped hotkeys. Returns true if the event was consumed. private func handleLocalKeyEvent(_ event: NSEvent) -> Bool { - // Tab hotkeys only when the notch is open and focused + // Local shortcuts only fire when the notch is open and focused. guard isNotchOpen else { return false } if newTabBinding.matches(event) { @@ -210,10 +224,25 @@ class HotkeyManager { onPreviousTab?() return true } + if nextWorkspaceBinding.matches(event) { + onNextWorkspace?() + return true + } + if previousWorkspaceBinding.matches(event) { + onPreviousWorkspace?() + return true + } if detachBinding.matches(event) { onDetachTab?() return true } + for summary in workspaceRegistry.workspaceSummaries { + guard let binding = summary.hotkey else { continue } + if binding.matches(event) { + onSwitchToWorkspace?(summary.id) + return true + } + } for preset in sizePresets { guard let binding = preset.hotkey else { continue } if binding.matches(event) { diff --git a/Downterm/CommandNotch/Managers/ScreenManager.swift b/Downterm/CommandNotch/Managers/ScreenManager.swift index 2c171f4..fe30ac7 100644 --- a/Downterm/CommandNotch/Managers/ScreenManager.swift +++ b/Downterm/CommandNotch/Managers/ScreenManager.swift @@ -9,6 +9,7 @@ final class ScreenManager: ObservableObject { static let shared = ScreenManager() private let screenRegistry = ScreenRegistry.shared + private let workspaceRegistry = WorkspaceRegistry.shared private let windowCoordinator = WindowCoordinator() private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self) @@ -55,6 +56,12 @@ final class ScreenManager: ObservableObject { hotkeyManager.onPreviousTab = { [weak self] in MainActor.assumeIsolated { self?.activeWorkspace().previousTab() } } + hotkeyManager.onNextWorkspace = { [weak self] in + MainActor.assumeIsolated { self?.switchWorkspace(offset: 1) } + } + hotkeyManager.onPreviousWorkspace = { [weak self] in + MainActor.assumeIsolated { self?.switchWorkspace(offset: -1) } + } hotkeyManager.onDetachTab = { [weak self] in MainActor.assumeIsolated { self?.detachActiveTab() } } @@ -64,6 +71,9 @@ final class ScreenManager: ObservableObject { hotkeyManager.onSwitchToTab = { [weak self] index in MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) } } + hotkeyManager.onSwitchToWorkspace = { [weak self] workspaceID in + MainActor.assumeIsolated { self?.switchActiveScreen(to: workspaceID) } + } hotkeyManager.start() } @@ -92,6 +102,33 @@ final class ScreenManager: ObservableObject { } } + private func switchWorkspace(offset: Int) { + guard let screenID = screenRegistry.activeScreenID() else { return } + let currentWorkspaceID = screenRegistry.screenContext(for: screenID)?.workspaceID ?? workspaceRegistry.defaultWorkspaceID + let nextWorkspaceID = offset >= 0 + ? workspaceRegistry.nextWorkspaceID(after: currentWorkspaceID) + : workspaceRegistry.previousWorkspaceID(before: currentWorkspaceID) + + guard let nextWorkspaceID else { return } + switchScreen(screenID, to: nextWorkspaceID) + } + + private func switchActiveScreen(to workspaceID: WorkspaceID) { + guard let screenID = screenRegistry.activeScreenID() else { return } + switchScreen(screenID, to: workspaceID) + } + + private func switchScreen(_ screenID: ScreenID, to workspaceID: WorkspaceID) { + screenRegistry.assignWorkspace(workspaceID, to: screenID) + + guard let context = screenRegistry.screenContext(for: screenID), + context.notchState == .open else { + return + } + + orchestrator.open(screenID: screenID) + } + func applySizePreset(_ preset: TerminalSizePreset) { guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else { AppSettingsController.shared.update { diff --git a/Downterm/CommandNotch/Models/AppSettings.swift b/Downterm/CommandNotch/Models/AppSettings.swift index f13b730..89ac030 100644 --- a/Downterm/CommandNotch/Models/AppSettings.swift +++ b/Downterm/CommandNotch/Models/AppSettings.swift @@ -56,6 +56,8 @@ struct AppSettings: Equatable, Codable { closeTab: .cmdW, nextTab: .cmdShiftRB, previousTab: .cmdShiftLB, + nextWorkspace: .cmdShiftDown, + previousWorkspace: .cmdShiftUp, detachTab: .cmdD ) ) @@ -121,6 +123,8 @@ extension AppSettings { var closeTab: HotkeyBinding var nextTab: HotkeyBinding var previousTab: HotkeyBinding + var nextWorkspace: HotkeyBinding + var previousWorkspace: HotkeyBinding var detachTab: HotkeyBinding } } diff --git a/Downterm/CommandNotch/Models/AppSettingsStore.swift b/Downterm/CommandNotch/Models/AppSettingsStore.swift index 855d6c9..74960bb 100644 --- a/Downterm/CommandNotch/Models/AppSettingsStore.swift +++ b/Downterm/CommandNotch/Models/AppSettingsStore.swift @@ -60,6 +60,8 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType { closeTab: hotkey(NotchSettings.Keys.hotkeyCloseTab, default: .cmdW), nextTab: hotkey(NotchSettings.Keys.hotkeyNextTab, default: .cmdShiftRB), previousTab: hotkey(NotchSettings.Keys.hotkeyPreviousTab, default: .cmdShiftLB), + nextWorkspace: hotkey(NotchSettings.Keys.hotkeyNextWorkspace, default: .cmdShiftDown), + previousWorkspace: hotkey(NotchSettings.Keys.hotkeyPreviousWorkspace, default: .cmdShiftUp), detachTab: hotkey(NotchSettings.Keys.hotkeyDetachTab, default: .cmdD) ) ) @@ -106,6 +108,8 @@ struct UserDefaultsAppSettingsStore: AppSettingsStoreType { defaults.set(settings.hotkeys.closeTab.toJSON(), forKey: NotchSettings.Keys.hotkeyCloseTab) defaults.set(settings.hotkeys.nextTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNextTab) defaults.set(settings.hotkeys.previousTab.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousTab) + defaults.set(settings.hotkeys.nextWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyNextWorkspace) + defaults.set(settings.hotkeys.previousWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousWorkspace) defaults.set(settings.hotkeys.detachTab.toJSON(), forKey: NotchSettings.Keys.hotkeyDetachTab) } diff --git a/Downterm/CommandNotch/Models/HotkeyBinding.swift b/Downterm/CommandNotch/Models/HotkeyBinding.swift index 0e767fd..0d5fd2d 100644 --- a/Downterm/CommandNotch/Models/HotkeyBinding.swift +++ b/Downterm/CommandNotch/Models/HotkeyBinding.swift @@ -88,6 +88,8 @@ struct HotkeyBinding: Codable, Equatable, Hashable { static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13) static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ] static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [ + static let cmdShiftDown = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 125) + static let cmdShiftUp = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 126) static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2) static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? { diff --git a/Downterm/CommandNotch/Models/NotchSettings.swift b/Downterm/CommandNotch/Models/NotchSettings.swift index e692097..b56dabd 100644 --- a/Downterm/CommandNotch/Models/NotchSettings.swift +++ b/Downterm/CommandNotch/Models/NotchSettings.swift @@ -57,6 +57,8 @@ enum NotchSettings { static let hotkeyCloseTab = "hotkey_closeTab" static let hotkeyNextTab = "hotkey_nextTab" static let hotkeyPreviousTab = "hotkey_previousTab" + static let hotkeyNextWorkspace = "hotkey_nextWorkspace" + static let hotkeyPreviousWorkspace = "hotkey_previousWorkspace" static let hotkeyDetachTab = "hotkey_detachTab" } @@ -104,6 +106,8 @@ enum NotchSettings { static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON() static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON() static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON() + static let hotkeyNextWorkspace: String = HotkeyBinding.cmdShiftDown.toJSON() + static let hotkeyPreviousWorkspace: String = HotkeyBinding.cmdShiftUp.toJSON() static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON() } @@ -151,6 +155,8 @@ enum NotchSettings { Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab, Keys.hotkeyNextTab: Defaults.hotkeyNextTab, Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab, + Keys.hotkeyNextWorkspace: Defaults.hotkeyNextWorkspace, + Keys.hotkeyPreviousWorkspace: Defaults.hotkeyPreviousWorkspace, Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab, ]) } diff --git a/Downterm/CommandNotch/Models/WorkspaceController.swift b/Downterm/CommandNotch/Models/WorkspaceController.swift index db7500f..73eb80e 100644 --- a/Downterm/CommandNotch/Models/WorkspaceController.swift +++ b/Downterm/CommandNotch/Models/WorkspaceController.swift @@ -18,6 +18,7 @@ final class WorkspaceController: ObservableObject { let createdAt: Date @Published private(set) var name: String + @Published private(set) var hotkey: HotkeyBinding? @Published private(set) var tabs: [TerminalSession] = [] @Published private(set) var activeTabIndex: Int = 0 @@ -34,6 +35,7 @@ final class WorkspaceController: ObservableObject { self.id = summary.id self.name = summary.name self.createdAt = summary.createdAt + self.hotkey = summary.hotkey self.sessionFactory = sessionFactory self.settingsProvider = settingsProvider @@ -51,7 +53,7 @@ final class WorkspaceController: ObservableObject { } var summary: WorkspaceSummary { - WorkspaceSummary(id: id, name: name, createdAt: createdAt) + WorkspaceSummary(id: id, name: name, createdAt: createdAt, hotkey: hotkey) } var state: WorkspaceState { @@ -78,6 +80,11 @@ final class WorkspaceController: ObservableObject { name = trimmed } + func updateHotkey(_ updatedHotkey: HotkeyBinding?) { + guard hotkey != updatedHotkey else { return } + hotkey = updatedHotkey + } + func newTab() { let config = settingsProvider.terminalSessionConfiguration let session = sessionFactory.makeSession( diff --git a/Downterm/CommandNotch/Models/WorkspaceRegistry.swift b/Downterm/CommandNotch/Models/WorkspaceRegistry.swift index 7dbcec4..caf893c 100644 --- a/Downterm/CommandNotch/Models/WorkspaceRegistry.swift +++ b/Downterm/CommandNotch/Models/WorkspaceRegistry.swift @@ -104,6 +104,37 @@ final class WorkspaceRegistry: ObservableObject { persistWorkspaceSummaries() } + func updateWorkspaceHotkey(id: WorkspaceID, to hotkey: HotkeyBinding?) { + guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { return } + guard workspaceSummaries[index].hotkey != hotkey else { return } + + workspaceSummaries[index].hotkey = hotkey + controllers[id]?.updateHotkey(hotkey) + persistWorkspaceSummaries() + } + + func nextWorkspaceID(after id: WorkspaceID) -> WorkspaceID? { + guard !workspaceSummaries.isEmpty else { return nil } + guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { + return workspaceSummaries.first?.id + } + + let nextIndex = workspaceSummaries.index(after: index) + return workspaceSummaries[nextIndex == workspaceSummaries.endIndex ? workspaceSummaries.startIndex : nextIndex].id + } + + func previousWorkspaceID(before id: WorkspaceID) -> WorkspaceID? { + guard !workspaceSummaries.isEmpty else { return nil } + guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { + return workspaceSummaries.last?.id + } + + let previousIndex = index == workspaceSummaries.startIndex + ? workspaceSummaries.index(before: workspaceSummaries.endIndex) + : workspaceSummaries.index(before: index) + return workspaceSummaries[previousIndex].id + } + @discardableResult func deleteWorkspace(id: WorkspaceID) -> Bool { guard canDeleteWorkspace(id: id) else { return false } diff --git a/Downterm/CommandNotch/Models/WorkspaceSummary.swift b/Downterm/CommandNotch/Models/WorkspaceSummary.swift index 1ad8593..2d8c442 100644 --- a/Downterm/CommandNotch/Models/WorkspaceSummary.swift +++ b/Downterm/CommandNotch/Models/WorkspaceSummary.swift @@ -6,11 +6,13 @@ struct WorkspaceSummary: Identifiable, Equatable, Codable { var id: WorkspaceID var name: String var createdAt: Date + var hotkey: HotkeyBinding? - init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date()) { + init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date(), hotkey: HotkeyBinding? = nil) { self.id = id self.name = name self.createdAt = createdAt + self.hotkey = hotkey } } diff --git a/Downterm/CommandNotch/Views/HotkeySettingsView.swift b/Downterm/CommandNotch/Views/HotkeySettingsView.swift index 71a92f9..259e430 100644 --- a/Downterm/CommandNotch/Views/HotkeySettingsView.swift +++ b/Downterm/CommandNotch/Views/HotkeySettingsView.swift @@ -17,8 +17,13 @@ struct HotkeySettingsView: View { HotkeyRecorderView(label: "Detach tab", binding: settingsController.binding(\.hotkeys.detachTab)) } + Section("Workspaces (active when notch is open)") { + HotkeyRecorderView(label: "Next workspace", binding: settingsController.binding(\.hotkeys.nextWorkspace)) + HotkeyRecorderView(label: "Previous workspace", binding: settingsController.binding(\.hotkeys.previousWorkspace)) + } + Section { - Text("⌘1–9 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.") + Text("⌘1–9 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets. Per-workspace jump hotkeys are configured in Workspaces.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Downterm/CommandNotch/Views/TerminalSettingsView.swift b/Downterm/CommandNotch/Views/TerminalSettingsView.swift index 1f60fd6..5662940 100644 --- a/Downterm/CommandNotch/Views/TerminalSettingsView.swift +++ b/Downterm/CommandNotch/Views/TerminalSettingsView.swift @@ -2,21 +2,7 @@ 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) - } - } - ) - } + @State private var sizePresets: [TerminalSizePreset] = [] var body: some View { Form { @@ -55,7 +41,7 @@ struct TerminalSettingsView: View { } Section("Size Presets") { - ForEach(sizePresetsBinding) { $preset in + ForEach($sizePresets) { $preset in TerminalSizePresetEditor( preset: $preset, currentOpenWidth: settingsController.settings.display.openWidth, @@ -67,20 +53,18 @@ struct TerminalSettingsView: View { HStack { Button("Add Preset") { - var presets = sizePresetsBinding.wrappedValue - presets.append( + sizePresets.append( TerminalSizePreset( - name: "Preset \(presets.count + 1)", + name: "Preset \(sizePresets.count + 1)", width: settingsController.settings.display.openWidth, height: settingsController.settings.display.openHeight, - hotkey: TerminalSizePresetStore.suggestedHotkey(for: presets) + hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets) ) ) - sizePresetsBinding.wrappedValue = presets } Button("Reset Presets") { - sizePresetsBinding.wrappedValue = TerminalSizePresetStore.loadDefaults() + sizePresets = TerminalSizePresetStore.loadDefaults() } } @@ -90,10 +74,24 @@ struct TerminalSettingsView: View { } } .formStyle(.grouped) + .onAppear { + synchronizePresetsFromSettings() + } + .onChange(of: settingsController.settings.terminal.sizePresetsJSON) { _, _ in + synchronizePresetsFromSettings() + } + .onChange(of: sizePresets) { _, newValue in + let encoded = TerminalSizePresetStore.encodePresets(newValue) + guard encoded != settingsController.settings.terminal.sizePresetsJSON else { return } + + settingsController.update { + $0.terminal.sizePresetsJSON = encoded + } + } } private func deletePreset(id: UUID) { - sizePresetsBinding.wrappedValue.removeAll { $0.id == id } + sizePresets.removeAll { $0.id == id } } private func applyPreset(_ preset: TerminalSizePreset) { @@ -103,6 +101,15 @@ struct TerminalSettingsView: View { } ScreenManager.shared.applySizePreset(preset) } + + private func synchronizePresetsFromSettings() { + let decoded = TerminalSizePresetStore.decodePresets( + from: settingsController.settings.terminal.sizePresetsJSON + ) ?? TerminalSizePresetStore.loadDefaults() + + guard decoded != sizePresets else { return } + sizePresets = decoded + } } private struct TerminalSizePresetEditor: View { diff --git a/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift b/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift index 39795ab..6fc8961 100644 --- a/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift +++ b/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift @@ -75,6 +75,11 @@ struct WorkspacesSettingsView: View { renameSelectedWorkspace() } + OptionalHotkeyRecorderView( + label: "Jump Hotkey", + binding: workspaceHotkeyBinding(for: summary.id) + ) + HStack { Button("Save Name") { renameSelectedWorkspace() @@ -86,6 +91,10 @@ struct WorkspacesSettingsView: View { } .accessibilityIdentifier("settings.workspaces.new") } + + Text("Workspace jump hotkeys are active when the notch is open and switch the current screen to this workspace.") + .font(.caption) + .foregroundStyle(.secondary) } Section("Usage") { @@ -256,6 +265,17 @@ struct WorkspacesSettingsView: View { renameDraft = workspaceRegistry.summary(for: workspaceID)?.name ?? "" } + private func workspaceHotkeyBinding(for workspaceID: WorkspaceID) -> Binding { + Binding( + get: { + workspaceRegistry.summary(for: workspaceID)?.hotkey + }, + set: { newValue in + workspaceRegistry.updateWorkspaceHotkey(id: workspaceID, to: newValue) + } + ) + } + private func deleteSelectedWorkspace() { guard let effectiveSelectedWorkspaceID, let fallbackWorkspaceID = screenRegistry.deleteWorkspace( diff --git a/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift b/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift index 49de141..193d1c1 100644 --- a/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift +++ b/Downterm/CommandNotchTests/WorkspaceRegistryTests.swift @@ -74,6 +74,30 @@ final class WorkspaceRegistryTests: XCTestCase { XCTAssertEqual(store.savedSummaries.map(\.name), ["Main"]) } + func testUpdateWorkspaceHotkeyPersistsAndUpdatesSummary() { + let store = InMemoryWorkspaceStore() + let registry = makeRegistry(store: store) + let docsID = registry.createWorkspace(named: "Docs") + let hotkey = HotkeyBinding.cmdShiftDigit(4) + + registry.updateWorkspaceHotkey(id: docsID, to: hotkey) + + XCTAssertEqual(registry.summary(for: docsID)?.hotkey, hotkey) + XCTAssertEqual(store.savedSummaries.last?.hotkey, hotkey) + } + + func testNextAndPreviousWorkspaceWrapAroundRegistryOrder() { + let registry = makeRegistry() + let mainID = registry.defaultWorkspaceID + let docsID = registry.createWorkspace(named: "Docs") + let reviewID = registry.createWorkspace(named: "Review") + + XCTAssertEqual(registry.nextWorkspaceID(after: mainID), docsID) + XCTAssertEqual(registry.nextWorkspaceID(after: reviewID), mainID) + XCTAssertEqual(registry.previousWorkspaceID(before: mainID), reviewID) + XCTAssertEqual(registry.previousWorkspaceID(before: docsID), mainID) + } + private func makeRegistry( initialWorkspaces: [WorkspaceSummary]? = [], store: (any WorkspaceStoreType)? = nil diff --git a/Downterm/CommandNotchTests/WorkspaceStoreTests.swift b/Downterm/CommandNotchTests/WorkspaceStoreTests.swift index 578388a..f88bc45 100644 --- a/Downterm/CommandNotchTests/WorkspaceStoreTests.swift +++ b/Downterm/CommandNotchTests/WorkspaceStoreTests.swift @@ -9,7 +9,11 @@ final class WorkspaceStoreTests: XCTestCase { let store = UserDefaultsWorkspaceStore(defaults: defaults) let summaries = [ - WorkspaceSummary(id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, name: "Main"), + WorkspaceSummary( + id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, + name: "Main", + hotkey: HotkeyBinding.cmdShiftDigit(4) + ), WorkspaceSummary(id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, name: "Docs") ]