From cf6ae2b72dd55984c7859c52385a91a3e1b812d3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:01:20 -0800 Subject: [PATCH] Add KeyboardShortcutSettings to enable customizable shortcuts The SettingsView was referencing KeyboardShortcutSettings but the file was not committed, causing CI build failures. --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Sources/KeyboardShortcutSettings.swift | 227 +++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 Sources/KeyboardShortcutSettings.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 024a3ab0..ab74618f 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ A50010A5 /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A1 /* SplitView.swift */; }; A50010A6 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A2 /* TerminalSplitTreeView.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; + A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; }; A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; }; A5001202 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001212 /* UpdateDelegate.swift */; }; A5001203 /* UpdateDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001213 /* UpdateDriver.swift */; }; @@ -46,6 +47,7 @@ B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; + B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -109,6 +111,7 @@ A50010A1 /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/SplitView.swift; sourceTree = ""; }; A50010A2 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/TerminalSplitTreeView.swift; sourceTree = ""; }; A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; + A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = ""; }; A5001211 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateController.swift; sourceTree = ""; }; A5001212 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDelegate.swift; sourceTree = ""; }; A5001213 /* UpdateDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDriver.swift; sourceTree = ""; }; @@ -131,6 +134,7 @@ B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxterm.swift; sourceTree = ""; }; B9000004A1B2C3D4E5F60719 /* cmuxterm */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmuxterm; sourceTree = BUILT_PRODUCTS_DIR; }; B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = ""; }; + B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadUITests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -221,6 +225,7 @@ A5001011 /* cmuxApp.swift */, A5001012 /* ContentView.swift */, A50012F0 /* Backport.swift */, + A50012F2 /* KeyboardShortcutSettings.swift */, A5001013 /* TabManager.swift */, A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, @@ -281,6 +286,7 @@ isa = PBXGroup; children = ( B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */, + B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */, 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, ); @@ -391,6 +397,7 @@ A5001001 /* cmuxApp.swift in Sources */, A5001002 /* ContentView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, + A50012F3 /* KeyboardShortcutSettings.swift in Sources */, A5001003 /* TabManager.swift in Sources */, A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, @@ -425,6 +432,7 @@ buildActionMask = 2147483647; files = ( B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */, + B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, ); diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift new file mode 100644 index 00000000..bada6695 --- /dev/null +++ b/Sources/KeyboardShortcutSettings.swift @@ -0,0 +1,227 @@ +import AppKit +import SwiftUI + +/// Stores customizable keyboard shortcuts +enum KeyboardShortcutSettings { + static let showNotificationsKey = "shortcut.showNotifications" + static let jumpToUnreadKey = "shortcut.jumpToUnread" + + /// Default shortcut: Cmd+Shift+I + static let showNotificationsDefault = StoredShortcut(key: "i", command: true, shift: true, option: false, control: false) + /// Default shortcut: Cmd+Shift+U + static let jumpToUnreadDefault = StoredShortcut(key: "u", command: true, shift: true, option: false, control: false) + + static func showNotificationsShortcut() -> StoredShortcut { + guard let data = UserDefaults.standard.data(forKey: showNotificationsKey), + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return showNotificationsDefault + } + return shortcut + } + + static func setShowNotificationsShortcut(_ shortcut: StoredShortcut) { + if let data = try? JSONEncoder().encode(shortcut) { + UserDefaults.standard.set(data, forKey: showNotificationsKey) + } + } + + static func jumpToUnreadShortcut() -> StoredShortcut { + guard let data = UserDefaults.standard.data(forKey: jumpToUnreadKey), + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return jumpToUnreadDefault + } + return shortcut + } + + static func setJumpToUnreadShortcut(_ shortcut: StoredShortcut) { + if let data = try? JSONEncoder().encode(shortcut) { + UserDefaults.standard.set(data, forKey: jumpToUnreadKey) + } + } + + static func resetAll() { + UserDefaults.standard.removeObject(forKey: showNotificationsKey) + UserDefaults.standard.removeObject(forKey: jumpToUnreadKey) + } +} + +/// A keyboard shortcut that can be stored in UserDefaults +struct StoredShortcut: Codable, Equatable { + var key: String + var command: Bool + var shift: Bool + var option: Bool + var control: Bool + + var displayString: String { + var parts: [String] = [] + if control { parts.append("⌃") } + if option { parts.append("⌥") } + if shift { parts.append("⇧") } + if command { parts.append("⌘") } + parts.append(key.uppercased()) + return parts.joined() + } + + var modifierFlags: NSEvent.ModifierFlags { + var flags: NSEvent.ModifierFlags = [] + if command { flags.insert(.command) } + if shift { flags.insert(.shift) } + if option { flags.insert(.option) } + if control { flags.insert(.control) } + return flags + } + + static func from(event: NSEvent) -> StoredShortcut? { + guard let chars = event.charactersIgnoringModifiers?.lowercased(), + let char = chars.first, + char.isLetter || char.isNumber else { + return nil + } + + let flags = event.modifierFlags + return StoredShortcut( + key: String(char), + command: flags.contains(.command), + shift: flags.contains(.shift), + option: flags.contains(.option), + control: flags.contains(.control) + ) + } +} + +/// View for recording a keyboard shortcut +struct KeyboardShortcutRecorder: View { + let label: String + @Binding var shortcut: StoredShortcut + @State private var isRecording = false + + var body: some View { + HStack { + Text(label) + + Spacer() + + ShortcutRecorderButton(shortcut: $shortcut, isRecording: $isRecording) + .frame(width: 120) + } + } +} + +private struct ShortcutRecorderButton: NSViewRepresentable { + @Binding var shortcut: StoredShortcut + @Binding var isRecording: Bool + + func makeNSView(context: Context) -> ShortcutRecorderNSButton { + let button = ShortcutRecorderNSButton() + button.shortcut = shortcut + button.onShortcutRecorded = { newShortcut in + shortcut = newShortcut + isRecording = false + } + button.onRecordingChanged = { recording in + isRecording = recording + } + return button + } + + func updateNSView(_ nsView: ShortcutRecorderNSButton, context: Context) { + nsView.shortcut = shortcut + nsView.updateTitle() + } +} + +private class ShortcutRecorderNSButton: NSButton { + var shortcut: StoredShortcut = KeyboardShortcutSettings.showNotificationsDefault + var onShortcutRecorded: ((StoredShortcut) -> Void)? + var onRecordingChanged: ((Bool) -> Void)? + private var isRecording = false + private var eventMonitor: Any? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + bezelStyle = .rounded + setButtonType(.momentaryPushIn) + target = self + action = #selector(buttonClicked) + updateTitle() + } + + func updateTitle() { + if isRecording { + title = "Press shortcut…" + } else { + title = shortcut.displayString + } + } + + @objc private func buttonClicked() { + if isRecording { + stopRecording() + } else { + startRecording() + } + } + + private func startRecording() { + isRecording = true + onRecordingChanged?(true) + updateTitle() + + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self = self else { return event } + + if event.keyCode == 53 { // Escape + self.stopRecording() + return nil + } + + if let newShortcut = StoredShortcut.from(event: event) { + self.shortcut = newShortcut + self.onShortcutRecorded?(newShortcut) + self.stopRecording() + return nil + } + + return event + } + + // Also stop recording if window loses focus + NotificationCenter.default.addObserver( + self, + selector: #selector(windowResigned), + name: NSWindow.didResignKeyNotification, + object: window + ) + } + + private func stopRecording() { + isRecording = false + onRecordingChanged?(false) + updateTitle() + + if let monitor = eventMonitor { + NSEvent.removeMonitor(monitor) + eventMonitor = nil + } + + NotificationCenter.default.removeObserver(self, name: NSWindow.didResignKeyNotification, object: window) + } + + @objc private func windowResigned() { + stopRecording() + } + + deinit { + stopRecording() + } +}