Add KeyboardShortcutSettings to enable customizable shortcuts

The SettingsView was referencing KeyboardShortcutSettings but the file
was not committed, causing CI build failures.
This commit is contained in:
Lawrence Chen 2026-01-29 19:01:20 -08:00
parent f622dff0a4
commit cf6ae2b72d
2 changed files with 235 additions and 0 deletions

View file

@ -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 = "<group>"; };
A50010A2 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/TerminalSplitTreeView.swift; sourceTree = "<group>"; };
A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = "<group>"; };
A5001211 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateController.swift; sourceTree = "<group>"; };
A5001212 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDelegate.swift; sourceTree = "<group>"; };
A5001213 /* UpdateDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDriver.swift; sourceTree = "<group>"; };
@ -131,6 +134,7 @@
B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxterm.swift; sourceTree = "<group>"; };
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 = "<group>"; };
B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadUITests.swift; sourceTree = "<group>"; };
/* 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 */,
);

View file

@ -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()
}
}