diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4ad323d8..87fb732a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1967,6 +1967,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWorkspace)) { + tabManager?.closeCurrentWorkspaceWithConfirmation() + return true + } + // Numeric shortcuts for specific sidebar tabs: Cmd+1-9 (9 = last workspace) if flags == [.command], let manager = tabManager, @@ -3305,6 +3310,9 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate { stateHintItem.title = snapshot.stateHintTitle + applyShortcut(KeyboardShortcutSettings.shortcut(for: .showNotifications), to: showNotificationsItem) + applyShortcut(KeyboardShortcutSettings.shortcut(for: .jumpToUnread), to: jumpToUnreadItem) + jumpToUnreadItem.isEnabled = snapshot.hasUnreadNotifications markAllReadItem.isEnabled = snapshot.hasUnreadNotifications clearAllItem.isEnabled = snapshot.hasNotifications @@ -3319,6 +3327,16 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate { } } + private func applyShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { + guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + return + } + item.keyEquivalent = keyEquivalent + item.keyEquivalentModifierMask = shortcut.modifierFlags + } + private func rebuildInlineNotificationItems(recentNotifications: [TerminalNotification]) { for item in notificationItems { menu.removeItem(item) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4ad00b12..470c5d81 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2544,7 +2544,7 @@ private struct TabItemView: View { .foregroundColor(isActive ? .white.opacity(0.7) : .secondary) } .buttonStyle(.plain) - .help("Close Workspace (\(StoredShortcut(key: "w", command: true, shift: true, option: false, control: false).displayString))") + .help(KeyboardShortcutSettings.Action.closeWorkspace.tooltip("Close Workspace")) .frame(width: 16, height: 16, alignment: .center) .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) @@ -2772,6 +2772,8 @@ private struct TabItemView: View { let closeLabel = targetIds.count > 1 ? "Close Workspaces" : "Close Workspace" let markReadLabel = targetIds.count > 1 ? "Mark Workspaces as Read" : "Mark Workspace as Read" let markUnreadLabel = targetIds.count > 1 ? "Mark Workspaces as Unread" : "Mark Workspace as Unread" + let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) + let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) Button(pinLabel) { for id in targetIds { if let tab = tabManager.tabs.first(where: { $0.id == id }) { @@ -2781,8 +2783,15 @@ private struct TabItemView: View { syncSelectionAfterMutation() } - Button("Rename Workspace…") { - promptRename() + if let key = renameWorkspaceShortcut.keyEquivalent { + Button("Rename Workspace…") { + promptRename() + } + .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) + } else { + Button("Rename Workspace…") { + promptRename() + } } if tab.hasCustomTitle { @@ -2811,10 +2820,18 @@ private struct TabItemView: View { Divider() - Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) + if let key = closeWorkspaceShortcut.keyEquivalent { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) + .disabled(targetIds.isEmpty) + } else { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .disabled(targetIds.isEmpty) } - .disabled(targetIds.isEmpty) Button("Close Other Workspaces") { closeOtherTabs(targetIds) diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 6d1d0978..4316a41e 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -18,6 +18,7 @@ enum KeyboardShortcutSettings { case nextSidebarTab case prevSidebarTab case renameWorkspace + case closeWorkspace case newSurface // Panes / splits @@ -50,6 +51,7 @@ enum KeyboardShortcutSettings { case .nextSidebarTab: return "Next Workspace" case .prevSidebarTab: return "Previous Workspace" case .renameWorkspace: return "Rename Workspace" + case .closeWorkspace: return "Close Workspace" case .newSurface: return "New Surface" case .focusLeft: return "Focus Pane Left" case .focusRight: return "Focus Pane Right" @@ -76,6 +78,7 @@ enum KeyboardShortcutSettings { case .nextSidebarTab: return "shortcut.nextSidebarTab" case .prevSidebarTab: return "shortcut.prevSidebarTab" case .renameWorkspace: return "shortcut.renameWorkspace" + case .closeWorkspace: return "shortcut.closeWorkspace" case .focusLeft: return "shortcut.focusLeft" case .focusRight: return "shortcut.focusRight" case .focusUp: return "shortcut.focusUp" @@ -113,6 +116,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true) case .renameWorkspace: return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false) + case .closeWorkspace: + return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false) case .focusLeft: return StoredShortcut(key: "←", command: true, shift: false, option: true, control: false) case .focusRight: @@ -196,6 +201,7 @@ enum KeyboardShortcutSettings { static func nextSidebarTabShortcut() -> StoredShortcut { shortcut(for: .nextSidebarTab) } static func prevSidebarTabShortcut() -> StoredShortcut { shortcut(for: .prevSidebarTab) } static func renameWorkspaceShortcut() -> StoredShortcut { shortcut(for: .renameWorkspace) } + static func closeWorkspaceShortcut() -> StoredShortcut { shortcut(for: .closeWorkspace) } static func focusLeftShortcut() -> StoredShortcut { shortcut(for: .focusLeft) } static func focusRightShortcut() -> StoredShortcut { shortcut(for: .focusRight) } @@ -250,6 +256,65 @@ struct StoredShortcut: Codable, Equatable { return flags } + var keyEquivalent: KeyEquivalent? { + switch key { + case "←": + return .leftArrow + case "→": + return .rightArrow + case "↑": + return .upArrow + case "↓": + return .downArrow + case "\t": + return .tab + default: + let lowered = key.lowercased() + guard lowered.count == 1, let character = lowered.first else { return nil } + return KeyEquivalent(character) + } + } + + var eventModifiers: EventModifiers { + var modifiers: EventModifiers = [] + if command { + modifiers.insert(.command) + } + if shift { + modifiers.insert(.shift) + } + if option { + modifiers.insert(.option) + } + if control { + modifiers.insert(.control) + } + return modifiers + } + + var menuItemKeyEquivalent: String? { + switch key { + case "←": + guard let scalar = UnicodeScalar(NSLeftArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "→": + guard let scalar = UnicodeScalar(NSRightArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "↑": + guard let scalar = UnicodeScalar(NSUpArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "↓": + guard let scalar = UnicodeScalar(NSDownArrowFunctionKey) else { return nil } + return String(Character(scalar)) + case "\t": + return "\t" + default: + let lowered = key.lowercased() + guard lowered.count == 1 else { return nil } + return lowered + } + } + static func from(event: NSEvent) -> StoredShortcut? { guard let key = storedKey(from: event) else { return nil } diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 45e9e3f2..f04841ed 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -5,6 +5,7 @@ struct NotificationsPage: View { @EnvironmentObject var tabManager: TabManager @Binding var selection: SidebarSelection @FocusState private var focusedNotificationId: UUID? + @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() var body: some View { VStack(spacing: 0) { @@ -73,6 +74,8 @@ struct NotificationsPage: View { Spacer() if !notificationStore.notifications.isEmpty { + jumpToUnreadButton + Button("Clear All") { notificationStore.clearAll() } @@ -97,11 +100,76 @@ struct NotificationsPage: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + @ViewBuilder + private var jumpToUnreadButton: some View { + if let key = jumpToUnreadShortcut.keyEquivalent { + Button(action: { + AppDelegate.shared?.jumpToLatestUnread() + }) { + HStack(spacing: 6) { + Text("Jump to Latest Unread") + ShortcutAnnotation(text: jumpToUnreadShortcut.displayString) + } + } + .buttonStyle(.bordered) + .keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers) + .help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread")) + .disabled(!hasUnreadNotifications) + } else { + Button(action: { + AppDelegate.shared?.jumpToLatestUnread() + }) { + HStack(spacing: 6) { + Text("Jump to Latest Unread") + ShortcutAnnotation(text: jumpToUnreadShortcut.displayString) + } + } + .buttonStyle(.bordered) + .help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread")) + .disabled(!hasUnreadNotifications) + } + } + + private var jumpToUnreadShortcut: StoredShortcut { + decodeShortcut( + from: jumpToUnreadShortcutData, + fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut + ) + } + + private var hasUnreadNotifications: Bool { + notificationStore.notifications.contains(where: { !$0.isRead }) + } + + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { + guard !data.isEmpty, + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return fallback + } + return shortcut + } + private func tabTitle(for tabId: UUID) -> String? { AppDelegate.shared?.tabTitle(for: tabId) ?? tabManager.tabs.first(where: { $0.id == tabId })?.title } } +private struct ShortcutAnnotation: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } +} + private struct NotificationRow: View { let notification: TerminalNotification let tabTitle: String? diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 9be61c8e..eb81cf24 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -500,7 +500,7 @@ struct BrowserPanelView: View { } .buttonStyle(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) - .help("Toggle Developer Tools") + .help(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip("Toggle Developer Tools")) .accessibilityIdentifier("BrowserToggleDevToolsButton") } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index defce523..4ba398be 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -177,6 +177,8 @@ extension WorkspaceContentView { struct EmptyPanelView: View { @ObservedObject var workspace: Workspace let paneId: PaneID + @AppStorage(KeyboardShortcutSettings.Action.newSurface.defaultsKey) private var newSurfaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.openBrowser.defaultsKey) private var openBrowserShortcutData = Data() private struct ShortcutHint: View { let text: String @@ -211,6 +213,49 @@ struct EmptyPanelView: View { _ = workspace.newBrowserSurface(inPane: paneId) } + private var newSurfaceShortcut: StoredShortcut { + decodeShortcut(from: newSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.newSurface.defaultShortcut) + } + + private var openBrowserShortcut: StoredShortcut { + decodeShortcut(from: openBrowserShortcutData, fallback: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut) + } + + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { + guard !data.isEmpty, + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return fallback + } + return shortcut + } + + @ViewBuilder + private func emptyPaneActionButton( + title: String, + systemImage: String, + shortcut: StoredShortcut, + action: @escaping () -> Void + ) -> some View { + if let key = shortcut.keyEquivalent { + Button(action: action) { + HStack(spacing: 10) { + Label(title, systemImage: systemImage) + ShortcutHint(text: shortcut.displayString) + } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(key, modifiers: shortcut.eventModifiers) + } else { + Button(action: action) { + HStack(spacing: 10) { + Label(title, systemImage: systemImage) + ShortcutHint(text: shortcut.displayString) + } + } + .buttonStyle(.borderedProminent) + } + } + var body: some View { VStack(spacing: 16) { Image(systemName: "terminal.fill") @@ -222,27 +267,19 @@ struct EmptyPanelView: View { .foregroundStyle(.secondary) HStack(spacing: 12) { - Button { - createTerminal() - } label: { - HStack(spacing: 10) { - Label("Terminal", systemImage: "terminal.fill") - ShortcutHint(text: "⌘T") - } - } - .buttonStyle(.borderedProminent) - .keyboardShortcut("t", modifiers: [.command]) + emptyPaneActionButton( + title: "Terminal", + systemImage: "terminal.fill", + shortcut: newSurfaceShortcut, + action: createTerminal + ) - Button { - createBrowser() - } label: { - HStack(spacing: 10) { - Label("Browser", systemImage: "globe") - ShortcutHint(text: "⌘⇧L") - } - } - .buttonStyle(.borderedProminent) - .keyboardShortcut("l", modifiers: [.command, .shift]) + emptyPaneActionButton( + title: "Browser", + systemImage: "globe", + shortcut: openBrowserShortcut, + action: createBrowser + ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 2f74980c..60190705 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -13,6 +13,15 @@ struct cmuxApp: App { @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue + @AppStorage(KeyboardShortcutSettings.Action.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.newWindow.defaultsKey) private var newWindowShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.showNotifications.defaultsKey) private var showNotificationsShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.nextSurface.defaultsKey) private var nextSurfaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.prevSurface.defaultsKey) private var prevSurfaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey) private var nextWorkspaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey) private var prevWorkspaceShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) @@ -21,6 +30,8 @@ struct cmuxApp: App { private var showBrowserJavaScriptConsoleShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -257,11 +268,11 @@ struct cmuxApp: App { Divider() } - Button("Show Notifications") { + splitCommandButton(title: "Show Notifications", shortcut: showNotificationsMenuShortcut) { showNotificationsPopover() } - Button("Jump to Latest Unread") { + splitCommandButton(title: "Jump to Latest Unread", shortcut: jumpToUnreadMenuShortcut) { appDelegate.jumpToLatestUnread() } .disabled(!snapshot.hasUnreadNotifications) @@ -337,12 +348,11 @@ struct cmuxApp: App { // New tab commands CommandGroup(replacing: .newItem) { - Button("New Window") { + splitCommandButton(title: "New Window", shortcut: newWindowMenuShortcut) { appDelegate.openNewMainWindow(nil) } - .keyboardShortcut("n", modifiers: [.command, .shift]) - Button("New Workspace") { + splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) { (AppDelegate.shared?.tabManager ?? tabManager).addTab() } } @@ -359,10 +369,9 @@ struct cmuxApp: App { // Cmd+Shift+W closes the current workspace (with confirmation if needed). If this // is the last workspace, it closes the window. - Button("Close Workspace") { + splitCommandButton(title: "Close Workspace", shortcut: closeWorkspaceMenuShortcut) { closeTabOrWindow() } - .keyboardShortcut("w", modifiers: [.command, .shift]) Button("Reopen Closed Browser Panel") { _ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel() @@ -408,17 +417,17 @@ struct cmuxApp: App { // Tab navigation CommandGroup(after: .toolbar) { - Button("Toggle Sidebar") { + splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) { sidebarState.toggle() } Divider() - Button("Next Surface") { + splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) { (AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface() } - Button("Previous Surface") { + splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) { (AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface() } @@ -470,15 +479,15 @@ struct cmuxApp: App { BrowserHistoryStore.shared.clearHistory() } - Button("Next Workspace") { + splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) { (AppDelegate.shared?.tabManager ?? tabManager).selectNextTab() } - Button("Previous Workspace") { + splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) { (AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab() } - Button("Rename Workspace…") { + splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) { _ = AppDelegate.shared?.promptRenameSelectedWorkspace() } @@ -515,11 +524,11 @@ struct cmuxApp: App { Divider() - Button("Jump to Latest Unread") { + splitCommandButton(title: "Jump to Latest Unread", shortcut: jumpToUnreadMenuShortcut) { AppDelegate.shared?.jumpToLatestUnread() } - Button("Show Notifications") { + splitCommandButton(title: "Show Notifications", shortcut: showNotificationsMenuShortcut) { showNotificationsPopover() } } @@ -578,6 +587,54 @@ struct cmuxApp: App { decodeShortcut(from: splitRightShortcutData, fallback: KeyboardShortcutSettings.Action.splitRight.defaultShortcut) } + private var toggleSidebarMenuShortcut: StoredShortcut { + decodeShortcut(from: toggleSidebarShortcutData, fallback: KeyboardShortcutSettings.Action.toggleSidebar.defaultShortcut) + } + + private var newWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut(from: newWorkspaceShortcutData, fallback: KeyboardShortcutSettings.Action.newTab.defaultShortcut) + } + + private var newWindowMenuShortcut: StoredShortcut { + decodeShortcut(from: newWindowShortcutData, fallback: KeyboardShortcutSettings.Action.newWindow.defaultShortcut) + } + + private var showNotificationsMenuShortcut: StoredShortcut { + decodeShortcut( + from: showNotificationsShortcutData, + fallback: KeyboardShortcutSettings.Action.showNotifications.defaultShortcut + ) + } + + private var jumpToUnreadMenuShortcut: StoredShortcut { + decodeShortcut( + from: jumpToUnreadShortcutData, + fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut + ) + } + + private var nextSurfaceMenuShortcut: StoredShortcut { + decodeShortcut(from: nextSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.nextSurface.defaultShortcut) + } + + private var prevSurfaceMenuShortcut: StoredShortcut { + decodeShortcut(from: prevSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.prevSurface.defaultShortcut) + } + + private var nextWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: nextWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + ) + } + + private var prevWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: prevWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + ) + } + private var splitDownMenuShortcut: StoredShortcut { decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) } @@ -610,6 +667,20 @@ struct cmuxApp: App { ) } + private var renameWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: renameWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut + ) + } + + private var closeWorkspaceMenuShortcut: StoredShortcut { + decodeShortcut( + from: closeWorkspaceShortcutData, + fallback: KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } @@ -651,50 +722,14 @@ struct cmuxApp: App { @ViewBuilder private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View { - if let key = keyEquivalent(for: shortcut) { + if let key = shortcut.keyEquivalent { Button(title, action: action) - .keyboardShortcut(key, modifiers: eventModifiers(for: shortcut)) + .keyboardShortcut(key, modifiers: shortcut.eventModifiers) } else { Button(title, action: action) } } - private func keyEquivalent(for shortcut: StoredShortcut) -> KeyEquivalent? { - switch shortcut.key { - case "←": - return .leftArrow - case "→": - return .rightArrow - case "↑": - return .upArrow - case "↓": - return .downArrow - case "\t": - return .tab - default: - let lowered = shortcut.key.lowercased() - guard lowered.count == 1, let character = lowered.first else { return nil } - return KeyEquivalent(character) - } - } - - private func eventModifiers(for shortcut: StoredShortcut) -> EventModifiers { - var modifiers: EventModifiers = [] - if shortcut.command { - modifiers.insert(.command) - } - if shortcut.shift { - modifiers.insert(.shift) - } - if shortcut.option { - modifiers.insert(.option) - } - if shortcut.control { - modifiers.insert(.control) - } - return modifiers - } - private func closePanelOrWindow() { if let window = NSApp.keyWindow, window.identifier?.rawValue == "cmux.settings" { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0afa6dc2..0cd08c71 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1,6 +1,7 @@ import XCTest import AppKit import WebKit +import SwiftUI import ObjectiveC.runtime #if canImport(cmux_DEV) @@ -331,6 +332,82 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { XCTAssertFalse(shortcut.control) } + func testRenameWorkspaceShortcutConvertsToMenuShortcut() { + let shortcut = KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut + XCTAssertNotNil(shortcut.keyEquivalent) + XCTAssertTrue(shortcut.eventModifiers.contains(.command)) + XCTAssertTrue(shortcut.eventModifiers.contains(.shift)) + XCTAssertFalse(shortcut.eventModifiers.contains(.option)) + XCTAssertFalse(shortcut.eventModifiers.contains(.control)) + } + + func testCloseWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.label, "Close Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey, "shortcut.closeWorkspace") + + let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + XCTAssertEqual(shortcut.key, "w") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testCloseWorkspaceShortcutConvertsToMenuShortcut() { + let shortcut = KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut + XCTAssertNotNil(shortcut.keyEquivalent) + XCTAssertTrue(shortcut.eventModifiers.contains(.command)) + XCTAssertTrue(shortcut.eventModifiers.contains(.shift)) + XCTAssertFalse(shortcut.eventModifiers.contains(.option)) + XCTAssertFalse(shortcut.eventModifiers.contains(.control)) + } + + func testNextPreviousWorkspaceShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.label, "Next Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.label, "Previous Workspace") + XCTAssertEqual(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey, "shortcut.nextSidebarTab") + XCTAssertEqual(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey, "shortcut.prevSidebarTab") + + let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + XCTAssertEqual(nextShortcut.key, "]") + XCTAssertTrue(nextShortcut.command) + XCTAssertFalse(nextShortcut.shift) + XCTAssertFalse(nextShortcut.option) + XCTAssertTrue(nextShortcut.control) + + let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + XCTAssertEqual(prevShortcut.key, "[") + XCTAssertTrue(prevShortcut.command) + XCTAssertFalse(prevShortcut.shift) + XCTAssertFalse(prevShortcut.option) + XCTAssertTrue(prevShortcut.control) + } + + func testNextPreviousWorkspaceShortcutsConvertToMenuShortcut() { + let nextShortcut = KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut + XCTAssertNotNil(nextShortcut.keyEquivalent) + XCTAssertEqual(nextShortcut.menuItemKeyEquivalent, "]") + XCTAssertTrue(nextShortcut.eventModifiers.contains(.command)) + XCTAssertTrue(nextShortcut.eventModifiers.contains(.control)) + + let prevShortcut = KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut + XCTAssertNotNil(prevShortcut.keyEquivalent) + XCTAssertEqual(prevShortcut.menuItemKeyEquivalent, "[") + XCTAssertTrue(prevShortcut.eventModifiers.contains(.command)) + XCTAssertTrue(prevShortcut.eventModifiers.contains(.control)) + } + + func testMenuItemKeyEquivalentHandlesArrowAndTabKeys() { + XCTAssertNotNil(StoredShortcut(key: "←", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "→", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "↑", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertNotNil(StoredShortcut(key: "↓", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent) + XCTAssertEqual( + StoredShortcut(key: "\t", command: true, shift: false, option: false, control: false).menuItemKeyEquivalent, + "\t" + ) + } + func testShortcutDefaultsKeysRemainUnique() { let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey) XCTAssertEqual(Set(keys).count, keys.count)