@wangyaoshen/remux 0.3.8-dev.29e114b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- package/vitest.config.js +10 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// Reads and parses the user's Ghostty configuration file.
|
|
4
|
+
/// Ghostty config format: key = value, # comments, one setting per line.
|
|
5
|
+
/// Ref: ghostty-org/ghostty config file specification.
|
|
6
|
+
@MainActor
|
|
7
|
+
final class GhosttyConfig: Sendable {
|
|
8
|
+
|
|
9
|
+
// MARK: - Parsed values
|
|
10
|
+
|
|
11
|
+
let fontFamily: String?
|
|
12
|
+
let fontSize: CGFloat?
|
|
13
|
+
let theme: String?
|
|
14
|
+
let background: NSColor?
|
|
15
|
+
let foreground: NSColor?
|
|
16
|
+
let cursorColor: NSColor?
|
|
17
|
+
let selectionBackground: NSColor?
|
|
18
|
+
let selectionForeground: NSColor?
|
|
19
|
+
let backgroundOpacity: CGFloat?
|
|
20
|
+
let palette: [Int: NSColor] // ANSI palette 0-15
|
|
21
|
+
|
|
22
|
+
// MARK: - Cache
|
|
23
|
+
|
|
24
|
+
private static var cachedLight: GhosttyConfig?
|
|
25
|
+
private static var cachedDark: GhosttyConfig?
|
|
26
|
+
|
|
27
|
+
// MARK: - Standard config paths
|
|
28
|
+
|
|
29
|
+
/// Default user config path.
|
|
30
|
+
static let userConfigPath: String = {
|
|
31
|
+
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
32
|
+
return home + "/.config/ghostty/config"
|
|
33
|
+
}()
|
|
34
|
+
|
|
35
|
+
/// Directories to search for themes.
|
|
36
|
+
static let themeSearchPaths: [String] = {
|
|
37
|
+
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
38
|
+
return [
|
|
39
|
+
home + "/.config/ghostty/themes",
|
|
40
|
+
"/usr/share/ghostty/themes",
|
|
41
|
+
"/usr/local/share/ghostty/themes",
|
|
42
|
+
]
|
|
43
|
+
}()
|
|
44
|
+
|
|
45
|
+
// MARK: - Init (private; use load())
|
|
46
|
+
|
|
47
|
+
private init(values: [String: String]) {
|
|
48
|
+
fontFamily = values["font-family"]
|
|
49
|
+
fontSize = values["font-size"].flatMap { CGFloat(Double($0) ?? 0) }
|
|
50
|
+
theme = values["theme"]
|
|
51
|
+
background = values["background"].flatMap { Self.parseColor($0) }
|
|
52
|
+
foreground = values["foreground"].flatMap { Self.parseColor($0) }
|
|
53
|
+
cursorColor = values["cursor-color"].flatMap { Self.parseColor($0) }
|
|
54
|
+
selectionBackground = values["selection-background"].flatMap { Self.parseColor($0) }
|
|
55
|
+
selectionForeground = values["selection-foreground"].flatMap { Self.parseColor($0) }
|
|
56
|
+
|
|
57
|
+
if let opStr = values["background-opacity"], let op = Double(opStr) {
|
|
58
|
+
backgroundOpacity = CGFloat(max(0, min(1, op)))
|
|
59
|
+
} else {
|
|
60
|
+
backgroundOpacity = nil
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var pal: [Int: NSColor] = [:]
|
|
64
|
+
for i in 0...15 {
|
|
65
|
+
let key = "palette = \(i)" // ghostty uses "palette = N=RRGGBB" format
|
|
66
|
+
// Actually ghostty uses "palette = 0=#RRGGBB" in a single key
|
|
67
|
+
// But the parsed format from our parser will be "palette" with value "N=#RRGGBB"
|
|
68
|
+
// We handle palette entries specially below
|
|
69
|
+
_ = key
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Parse palette entries from the raw values (handled in parsePalette)
|
|
73
|
+
pal = [:] // Will be filled by load() using paletteEntries
|
|
74
|
+
palette = pal
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private init(
|
|
78
|
+
fontFamily: String?,
|
|
79
|
+
fontSize: CGFloat?,
|
|
80
|
+
theme: String?,
|
|
81
|
+
background: NSColor?,
|
|
82
|
+
foreground: NSColor?,
|
|
83
|
+
cursorColor: NSColor?,
|
|
84
|
+
selectionBackground: NSColor?,
|
|
85
|
+
selectionForeground: NSColor?,
|
|
86
|
+
backgroundOpacity: CGFloat?,
|
|
87
|
+
palette: [Int: NSColor]
|
|
88
|
+
) {
|
|
89
|
+
self.fontFamily = fontFamily
|
|
90
|
+
self.fontSize = fontSize
|
|
91
|
+
self.theme = theme
|
|
92
|
+
self.background = background
|
|
93
|
+
self.foreground = foreground
|
|
94
|
+
self.cursorColor = cursorColor
|
|
95
|
+
self.selectionBackground = selectionBackground
|
|
96
|
+
self.selectionForeground = selectionForeground
|
|
97
|
+
self.backgroundOpacity = backgroundOpacity
|
|
98
|
+
self.palette = palette
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// MARK: - Public API
|
|
102
|
+
|
|
103
|
+
/// Load the Ghostty configuration. Caches per color scheme.
|
|
104
|
+
static func load(forScheme scheme: ColorScheme = .current) -> GhosttyConfig {
|
|
105
|
+
switch scheme {
|
|
106
|
+
case .dark:
|
|
107
|
+
if let cached = cachedDark { return cached }
|
|
108
|
+
case .light:
|
|
109
|
+
if let cached = cachedLight { return cached }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let config = parseConfigFile(at: userConfigPath, scheme: scheme)
|
|
113
|
+
|
|
114
|
+
switch scheme {
|
|
115
|
+
case .dark: cachedDark = config
|
|
116
|
+
case .light: cachedLight = config
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return config
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Invalidate the cache (e.g., after detecting config file change).
|
|
123
|
+
static func invalidateCache() {
|
|
124
|
+
cachedLight = nil
|
|
125
|
+
cachedDark = nil
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
enum ColorScheme {
|
|
129
|
+
case light, dark
|
|
130
|
+
|
|
131
|
+
static var current: ColorScheme {
|
|
132
|
+
let appearance = NSApp.effectiveAppearance
|
|
133
|
+
if appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
|
|
134
|
+
return .dark
|
|
135
|
+
}
|
|
136
|
+
return .light
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// MARK: - Parsing
|
|
141
|
+
|
|
142
|
+
/// Parse a Ghostty config file into a GhosttyConfig instance.
|
|
143
|
+
private static func parseConfigFile(at path: String, scheme: ColorScheme) -> GhosttyConfig {
|
|
144
|
+
var values: [String: String] = [:]
|
|
145
|
+
var paletteEntries: [Int: NSColor] = [:]
|
|
146
|
+
|
|
147
|
+
// Parse main config file
|
|
148
|
+
if let lines = readConfigLines(at: path) {
|
|
149
|
+
parseLines(lines, into: &values, palette: &paletteEntries)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// If a theme is specified, load theme overrides
|
|
153
|
+
if let themeName = values["theme"] {
|
|
154
|
+
let themeValues = loadTheme(name: themeName, scheme: scheme)
|
|
155
|
+
// Theme values are overridden by user config, so merge theme first
|
|
156
|
+
var merged = themeValues.values
|
|
157
|
+
for (k, v) in values {
|
|
158
|
+
merged[k] = v
|
|
159
|
+
}
|
|
160
|
+
for (k, v) in themeValues.palette {
|
|
161
|
+
if paletteEntries[k] == nil {
|
|
162
|
+
paletteEntries[k] = v
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
values = merged
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return GhosttyConfig(
|
|
169
|
+
fontFamily: values["font-family"],
|
|
170
|
+
fontSize: values["font-size"].flatMap { CGFloat(Double($0) ?? 0) },
|
|
171
|
+
theme: values["theme"],
|
|
172
|
+
background: values["background"].flatMap { parseColor($0) },
|
|
173
|
+
foreground: values["foreground"].flatMap { parseColor($0) },
|
|
174
|
+
cursorColor: values["cursor-color"].flatMap { parseColor($0) },
|
|
175
|
+
selectionBackground: values["selection-background"].flatMap { parseColor($0) },
|
|
176
|
+
selectionForeground: values["selection-foreground"].flatMap { parseColor($0) },
|
|
177
|
+
backgroundOpacity: values["background-opacity"].flatMap { Double($0) }.map { CGFloat(max(0, min(1, $0))) },
|
|
178
|
+
palette: paletteEntries
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/// Read a config file and return its lines, skipping comments and empty lines.
|
|
183
|
+
private static func readConfigLines(at path: String) -> [String]? {
|
|
184
|
+
guard FileManager.default.fileExists(atPath: path),
|
|
185
|
+
let content = try? String(contentsOfFile: path, encoding: .utf8) else {
|
|
186
|
+
return nil
|
|
187
|
+
}
|
|
188
|
+
return content.components(separatedBy: .newlines)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Parse config lines into key-value pairs and palette entries.
|
|
192
|
+
private static func parseLines(
|
|
193
|
+
_ lines: [String],
|
|
194
|
+
into values: inout [String: String],
|
|
195
|
+
palette: inout [Int: NSColor]
|
|
196
|
+
) {
|
|
197
|
+
for rawLine in lines {
|
|
198
|
+
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
|
199
|
+
|
|
200
|
+
// Skip comments and empty lines
|
|
201
|
+
if line.isEmpty || line.hasPrefix("#") { continue }
|
|
202
|
+
|
|
203
|
+
// Split on first "=" sign
|
|
204
|
+
guard let eqIndex = line.firstIndex(of: "=") else { continue }
|
|
205
|
+
let key = String(line[line.startIndex..<eqIndex]).trimmingCharacters(in: .whitespaces)
|
|
206
|
+
let value = String(line[line.index(after: eqIndex)...]).trimmingCharacters(in: .whitespaces)
|
|
207
|
+
|
|
208
|
+
if key.isEmpty { continue }
|
|
209
|
+
|
|
210
|
+
// Handle palette entries: "palette = 0=#000000" or just key "palette" with value "0=#000000"
|
|
211
|
+
if key == "palette" {
|
|
212
|
+
if let parsed = parsePaletteEntry(value) {
|
|
213
|
+
palette[parsed.index] = parsed.color
|
|
214
|
+
}
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
values[key] = value
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// Parse a palette value like "0=#000000" into (index, color).
|
|
223
|
+
private static func parsePaletteEntry(_ value: String) -> (index: Int, color: NSColor)? {
|
|
224
|
+
// Format: "N=RRGGBB" or "N=#RRGGBB"
|
|
225
|
+
guard let eqIdx = value.firstIndex(of: "=") else { return nil }
|
|
226
|
+
let indexStr = String(value[value.startIndex..<eqIdx]).trimmingCharacters(in: .whitespaces)
|
|
227
|
+
let colorStr = String(value[value.index(after: eqIdx)...]).trimmingCharacters(in: .whitespaces)
|
|
228
|
+
|
|
229
|
+
guard let index = Int(indexStr), (0...15).contains(index) else { return nil }
|
|
230
|
+
guard let color = parseColor(colorStr) else { return nil }
|
|
231
|
+
return (index, color)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// Theme file result container.
|
|
235
|
+
private struct ThemeResult {
|
|
236
|
+
var values: [String: String] = [:]
|
|
237
|
+
var palette: [Int: NSColor] = [:]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// Load a theme by name from standard search paths.
|
|
241
|
+
private static func loadTheme(name: String, scheme: ColorScheme) -> ThemeResult {
|
|
242
|
+
// Ghostty supports "theme = auto" which picks light/dark variant
|
|
243
|
+
var themeName = name
|
|
244
|
+
if themeName == "auto" {
|
|
245
|
+
// No specific theme to load for "auto"
|
|
246
|
+
return ThemeResult()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Search theme files
|
|
250
|
+
for searchPath in themeSearchPaths {
|
|
251
|
+
let themePath = searchPath + "/" + themeName
|
|
252
|
+
if let lines = readConfigLines(at: themePath) {
|
|
253
|
+
var result = ThemeResult()
|
|
254
|
+
parseLines(lines, into: &result.values, palette: &result.palette)
|
|
255
|
+
return result
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Also check with common extensions
|
|
260
|
+
for ext in ["", ".conf", ".theme"] {
|
|
261
|
+
for searchPath in themeSearchPaths {
|
|
262
|
+
let themePath = searchPath + "/" + themeName + ext
|
|
263
|
+
if let lines = readConfigLines(at: themePath) {
|
|
264
|
+
var result = ThemeResult()
|
|
265
|
+
parseLines(lines, into: &result.values, palette: &result.palette)
|
|
266
|
+
return result
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return ThemeResult()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// MARK: - Color parsing
|
|
275
|
+
|
|
276
|
+
/// Parse a hex color string to NSColor.
|
|
277
|
+
/// Supports: "#RRGGBB", "RRGGBB", "#RGB", "RGB", "#RRGGBBAA"
|
|
278
|
+
static func parseColor(_ str: String) -> NSColor? {
|
|
279
|
+
var hex = str.trimmingCharacters(in: .whitespaces)
|
|
280
|
+
if hex.hasPrefix("#") {
|
|
281
|
+
hex = String(hex.dropFirst())
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Expand shorthand #RGB to #RRGGBB
|
|
285
|
+
if hex.count == 3 {
|
|
286
|
+
let chars = Array(hex)
|
|
287
|
+
hex = String([chars[0], chars[0], chars[1], chars[1], chars[2], chars[2]])
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let scanner = Scanner(string: hex)
|
|
291
|
+
var value: UInt64 = 0
|
|
292
|
+
guard scanner.scanHexInt64(&value) else { return nil }
|
|
293
|
+
|
|
294
|
+
if hex.count == 8 {
|
|
295
|
+
// RRGGBBAA
|
|
296
|
+
let r = CGFloat((value >> 24) & 0xFF) / 255.0
|
|
297
|
+
let g = CGFloat((value >> 16) & 0xFF) / 255.0
|
|
298
|
+
let b = CGFloat((value >> 8) & 0xFF) / 255.0
|
|
299
|
+
let a = CGFloat(value & 0xFF) / 255.0
|
|
300
|
+
return NSColor(srgbRed: r, green: g, blue: b, alpha: a)
|
|
301
|
+
} else if hex.count == 6 {
|
|
302
|
+
// RRGGBB
|
|
303
|
+
let r = CGFloat((value >> 16) & 0xFF) / 255.0
|
|
304
|
+
let g = CGFloat((value >> 8) & 0xFF) / 255.0
|
|
305
|
+
let b = CGFloat(value & 0xFF) / 255.0
|
|
306
|
+
return NSColor(srgbRed: r, green: g, blue: b, alpha: 1.0)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return nil
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// All bindable actions in the app.
|
|
4
|
+
/// Adapted from ghostty-org/ghostty key binding action catalog design.
|
|
5
|
+
enum ShortcutAction: String, CaseIterable, Codable, Sendable {
|
|
6
|
+
// Terminal
|
|
7
|
+
case find
|
|
8
|
+
case clearTerminal
|
|
9
|
+
|
|
10
|
+
// Tabs
|
|
11
|
+
case newTab
|
|
12
|
+
case closeTab
|
|
13
|
+
case nextTab
|
|
14
|
+
case prevTab
|
|
15
|
+
|
|
16
|
+
// Splits
|
|
17
|
+
case splitRight
|
|
18
|
+
case splitDown
|
|
19
|
+
case closePane
|
|
20
|
+
case focusNextPane
|
|
21
|
+
case focusPrevPane
|
|
22
|
+
|
|
23
|
+
// Window
|
|
24
|
+
case toggleSidebar
|
|
25
|
+
case toggleInspect
|
|
26
|
+
case toggleFullscreen
|
|
27
|
+
|
|
28
|
+
// Navigation (directional pane focus)
|
|
29
|
+
case focusLeft
|
|
30
|
+
case focusRight
|
|
31
|
+
case focusUp
|
|
32
|
+
case focusDown
|
|
33
|
+
|
|
34
|
+
/// Human-readable display name for settings UI.
|
|
35
|
+
var displayName: String {
|
|
36
|
+
switch self {
|
|
37
|
+
case .find: return "Find"
|
|
38
|
+
case .clearTerminal: return "Clear Terminal"
|
|
39
|
+
case .newTab: return "New Tab"
|
|
40
|
+
case .closeTab: return "Close Tab"
|
|
41
|
+
case .nextTab: return "Next Tab"
|
|
42
|
+
case .prevTab: return "Previous Tab"
|
|
43
|
+
case .splitRight: return "Split Right"
|
|
44
|
+
case .splitDown: return "Split Down"
|
|
45
|
+
case .closePane: return "Close Pane"
|
|
46
|
+
case .focusNextPane: return "Focus Next Pane"
|
|
47
|
+
case .focusPrevPane: return "Focus Previous Pane"
|
|
48
|
+
case .toggleSidebar: return "Toggle Sidebar"
|
|
49
|
+
case .toggleInspect: return "Toggle Inspect"
|
|
50
|
+
case .toggleFullscreen: return "Toggle Fullscreen"
|
|
51
|
+
case .focusLeft: return "Focus Left"
|
|
52
|
+
case .focusRight: return "Focus Right"
|
|
53
|
+
case .focusUp: return "Focus Up"
|
|
54
|
+
case .focusDown: return "Focus Down"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Category for grouping in the settings UI.
|
|
59
|
+
var category: String {
|
|
60
|
+
switch self {
|
|
61
|
+
case .find, .clearTerminal:
|
|
62
|
+
return "Terminal"
|
|
63
|
+
case .newTab, .closeTab, .nextTab, .prevTab:
|
|
64
|
+
return "Tabs"
|
|
65
|
+
case .splitRight, .splitDown, .closePane, .focusNextPane, .focusPrevPane:
|
|
66
|
+
return "Splits"
|
|
67
|
+
case .toggleSidebar, .toggleInspect, .toggleFullscreen:
|
|
68
|
+
return "Window"
|
|
69
|
+
case .focusLeft, .focusRight, .focusUp, .focusDown:
|
|
70
|
+
return "Navigation"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Default keyboard shortcut for this action.
|
|
75
|
+
var defaultShortcut: StoredShortcut {
|
|
76
|
+
switch self {
|
|
77
|
+
case .find:
|
|
78
|
+
return StoredShortcut(key: "f", command: true)
|
|
79
|
+
case .clearTerminal:
|
|
80
|
+
return StoredShortcut(key: "k", command: true)
|
|
81
|
+
case .newTab:
|
|
82
|
+
return StoredShortcut(key: "t", command: true)
|
|
83
|
+
case .closeTab:
|
|
84
|
+
return StoredShortcut(key: "w", command: true)
|
|
85
|
+
case .nextTab:
|
|
86
|
+
return StoredShortcut(key: "}", command: true, shift: true)
|
|
87
|
+
case .prevTab:
|
|
88
|
+
return StoredShortcut(key: "{", command: true, shift: true)
|
|
89
|
+
case .splitRight:
|
|
90
|
+
return StoredShortcut(key: "d", command: true)
|
|
91
|
+
case .splitDown:
|
|
92
|
+
return StoredShortcut(key: "d", command: true, shift: true)
|
|
93
|
+
case .closePane:
|
|
94
|
+
return StoredShortcut(key: "w", command: true, shift: true)
|
|
95
|
+
case .focusNextPane:
|
|
96
|
+
return StoredShortcut(key: "]", command: true, option: true)
|
|
97
|
+
case .focusPrevPane:
|
|
98
|
+
return StoredShortcut(key: "[", command: true, option: true)
|
|
99
|
+
case .toggleSidebar:
|
|
100
|
+
return StoredShortcut(key: "s", command: true, control: true)
|
|
101
|
+
case .toggleInspect:
|
|
102
|
+
return StoredShortcut(key: "i", command: true)
|
|
103
|
+
case .toggleFullscreen:
|
|
104
|
+
return StoredShortcut(key: "f", command: true, control: true)
|
|
105
|
+
case .focusLeft:
|
|
106
|
+
return StoredShortcut(key: "left", command: true, option: true)
|
|
107
|
+
case .focusRight:
|
|
108
|
+
return StoredShortcut(key: "right", command: true, option: true)
|
|
109
|
+
case .focusUp:
|
|
110
|
+
return StoredShortcut(key: "up", command: true, option: true)
|
|
111
|
+
case .focusDown:
|
|
112
|
+
return StoredShortcut(key: "down", command: true, option: true)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// Settings view for customizing keyboard shortcuts.
|
|
4
|
+
/// Adapted from ghostty-org/ghostty macOS keybinding preferences pattern.
|
|
5
|
+
struct ShortcutSettingsView: View {
|
|
6
|
+
@State private var shortcuts: [ShortcutAction: StoredShortcut] = loadAllShortcuts()
|
|
7
|
+
@State private var recordingAction: ShortcutAction?
|
|
8
|
+
@State private var conflicts: [ShortcutAction: [ShortcutAction]] = [:]
|
|
9
|
+
|
|
10
|
+
var body: some View {
|
|
11
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
12
|
+
// Header
|
|
13
|
+
HStack {
|
|
14
|
+
Text("Keyboard Shortcuts")
|
|
15
|
+
.font(.headline)
|
|
16
|
+
Spacer()
|
|
17
|
+
Button("Reset All") {
|
|
18
|
+
resetAll()
|
|
19
|
+
}
|
|
20
|
+
.controlSize(.small)
|
|
21
|
+
}
|
|
22
|
+
.padding(.horizontal, 16)
|
|
23
|
+
.padding(.vertical, 12)
|
|
24
|
+
|
|
25
|
+
Divider()
|
|
26
|
+
|
|
27
|
+
// Shortcut list grouped by category
|
|
28
|
+
ScrollView {
|
|
29
|
+
LazyVStack(alignment: .leading, spacing: 0) {
|
|
30
|
+
let categories = orderedCategories()
|
|
31
|
+
ForEach(categories, id: \.self) { category in
|
|
32
|
+
let actions = ShortcutAction.allCases.filter { $0.category == category }
|
|
33
|
+
Section {
|
|
34
|
+
ForEach(actions, id: \.self) { action in
|
|
35
|
+
ShortcutRow(
|
|
36
|
+
action: action,
|
|
37
|
+
shortcut: shortcuts[action] ?? action.defaultShortcut,
|
|
38
|
+
isRecording: recordingAction == action,
|
|
39
|
+
hasConflict: !(conflicts[action]?.isEmpty ?? true),
|
|
40
|
+
onStartRecording: {
|
|
41
|
+
recordingAction = action
|
|
42
|
+
},
|
|
43
|
+
onRecorded: { newShortcut in
|
|
44
|
+
applyShortcut(newShortcut, for: action)
|
|
45
|
+
},
|
|
46
|
+
onReset: {
|
|
47
|
+
resetShortcut(for: action)
|
|
48
|
+
},
|
|
49
|
+
onCancelRecording: {
|
|
50
|
+
recordingAction = nil
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
} header: {
|
|
55
|
+
Text(category)
|
|
56
|
+
.font(.subheadline.weight(.semibold))
|
|
57
|
+
.foregroundStyle(.secondary)
|
|
58
|
+
.padding(.horizontal, 16)
|
|
59
|
+
.padding(.top, 12)
|
|
60
|
+
.padding(.bottom, 4)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
.padding(.bottom, 12)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
.frame(minWidth: 500, minHeight: 400)
|
|
68
|
+
.onAppear { refreshConflicts() }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// MARK: - Actions
|
|
72
|
+
|
|
73
|
+
private func applyShortcut(_ shortcut: StoredShortcut, for action: ShortcutAction) {
|
|
74
|
+
StoredShortcut.setShortcut(shortcut, for: action)
|
|
75
|
+
shortcuts[action] = shortcut
|
|
76
|
+
recordingAction = nil
|
|
77
|
+
refreshConflicts()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private func resetShortcut(for action: ShortcutAction) {
|
|
81
|
+
StoredShortcut.resetShortcut(for: action)
|
|
82
|
+
shortcuts[action] = action.defaultShortcut
|
|
83
|
+
recordingAction = nil
|
|
84
|
+
refreshConflicts()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private func resetAll() {
|
|
88
|
+
StoredShortcut.resetAll()
|
|
89
|
+
shortcuts = Self.loadAllShortcuts()
|
|
90
|
+
recordingAction = nil
|
|
91
|
+
refreshConflicts()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private func refreshConflicts() {
|
|
95
|
+
var newConflicts: [ShortcutAction: [ShortcutAction]] = [:]
|
|
96
|
+
for action in ShortcutAction.allCases {
|
|
97
|
+
let found = StoredShortcut.conflicts(for: action)
|
|
98
|
+
if !found.isEmpty {
|
|
99
|
+
newConflicts[action] = found
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
conflicts = newConflicts
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private func orderedCategories() -> [String] {
|
|
106
|
+
// Maintain a stable order
|
|
107
|
+
["Terminal", "Tabs", "Splits", "Window", "Navigation"]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private static func loadAllShortcuts() -> [ShortcutAction: StoredShortcut] {
|
|
111
|
+
var map: [ShortcutAction: StoredShortcut] = [:]
|
|
112
|
+
for action in ShortcutAction.allCases {
|
|
113
|
+
map[action] = StoredShortcut.shortcut(for: action)
|
|
114
|
+
}
|
|
115
|
+
return map
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// MARK: - Shortcut Row
|
|
120
|
+
|
|
121
|
+
/// A single row in the shortcut list: action name + shortcut badge + record/reset controls.
|
|
122
|
+
struct ShortcutRow: View {
|
|
123
|
+
let action: ShortcutAction
|
|
124
|
+
let shortcut: StoredShortcut
|
|
125
|
+
let isRecording: Bool
|
|
126
|
+
let hasConflict: Bool
|
|
127
|
+
var onStartRecording: () -> Void
|
|
128
|
+
var onRecorded: (StoredShortcut) -> Void
|
|
129
|
+
var onReset: () -> Void
|
|
130
|
+
var onCancelRecording: () -> Void
|
|
131
|
+
|
|
132
|
+
@State private var isHovering = false
|
|
133
|
+
|
|
134
|
+
var body: some View {
|
|
135
|
+
HStack {
|
|
136
|
+
Text(action.displayName)
|
|
137
|
+
.frame(minWidth: 160, alignment: .leading)
|
|
138
|
+
|
|
139
|
+
Spacer()
|
|
140
|
+
|
|
141
|
+
if isRecording {
|
|
142
|
+
ShortcutRecorderField(
|
|
143
|
+
onRecorded: onRecorded,
|
|
144
|
+
onCancel: onCancelRecording
|
|
145
|
+
)
|
|
146
|
+
.frame(width: 140)
|
|
147
|
+
} else {
|
|
148
|
+
// Shortcut display badge
|
|
149
|
+
Text(shortcut.displayString)
|
|
150
|
+
.font(.system(size: 12, design: .monospaced))
|
|
151
|
+
.padding(.horizontal, 8)
|
|
152
|
+
.padding(.vertical, 3)
|
|
153
|
+
.background(
|
|
154
|
+
RoundedRectangle(cornerRadius: 4)
|
|
155
|
+
.fill(hasConflict ? Color.red.opacity(0.15) : Color.primary.opacity(0.06))
|
|
156
|
+
)
|
|
157
|
+
.overlay(
|
|
158
|
+
RoundedRectangle(cornerRadius: 4)
|
|
159
|
+
.stroke(hasConflict ? Color.red.opacity(0.4) : Color.clear, lineWidth: 1)
|
|
160
|
+
)
|
|
161
|
+
.onTapGesture { onStartRecording() }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Reset button (shown on hover or if customized)
|
|
165
|
+
if isHovering && shortcut != action.defaultShortcut {
|
|
166
|
+
Button(action: onReset) {
|
|
167
|
+
Image(systemName: "arrow.counterclockwise")
|
|
168
|
+
.font(.system(size: 10))
|
|
169
|
+
.foregroundStyle(.secondary)
|
|
170
|
+
}
|
|
171
|
+
.buttonStyle(.plain)
|
|
172
|
+
.help("Reset to Default")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if hasConflict {
|
|
176
|
+
Image(systemName: "exclamationmark.triangle.fill")
|
|
177
|
+
.font(.system(size: 10))
|
|
178
|
+
.foregroundStyle(.yellow)
|
|
179
|
+
.help("Shortcut conflict detected")
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
.padding(.horizontal, 16)
|
|
183
|
+
.padding(.vertical, 5)
|
|
184
|
+
.background(isHovering ? Color.primary.opacity(0.03) : Color.clear)
|
|
185
|
+
.onHover { isHovering = $0 }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// MARK: - Shortcut Recorder
|
|
190
|
+
|
|
191
|
+
/// An inline field that captures the next key event as a new shortcut.
|
|
192
|
+
struct ShortcutRecorderField: NSViewRepresentable {
|
|
193
|
+
var onRecorded: (StoredShortcut) -> Void
|
|
194
|
+
var onCancel: () -> Void
|
|
195
|
+
|
|
196
|
+
func makeNSView(context: Context) -> ShortcutRecorderNSView {
|
|
197
|
+
let view = ShortcutRecorderNSView()
|
|
198
|
+
view.onRecorded = onRecorded
|
|
199
|
+
view.onCancel = onCancel
|
|
200
|
+
// Become first responder after a brief delay to ensure the view is in the hierarchy
|
|
201
|
+
DispatchQueue.main.async {
|
|
202
|
+
view.window?.makeFirstResponder(view)
|
|
203
|
+
}
|
|
204
|
+
return view
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func updateNSView(_ nsView: ShortcutRecorderNSView, context: Context) {
|
|
208
|
+
nsView.onRecorded = onRecorded
|
|
209
|
+
nsView.onCancel = onCancel
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// NSView that captures a single key event for shortcut recording.
|
|
214
|
+
@MainActor
|
|
215
|
+
final class ShortcutRecorderNSView: NSView {
|
|
216
|
+
var onRecorded: ((StoredShortcut) -> Void)?
|
|
217
|
+
var onCancel: (() -> Void)?
|
|
218
|
+
|
|
219
|
+
override var acceptsFirstResponder: Bool { true }
|
|
220
|
+
|
|
221
|
+
override init(frame frameRect: NSRect) {
|
|
222
|
+
super.init(frame: frameRect)
|
|
223
|
+
wantsLayer = true
|
|
224
|
+
layer?.cornerRadius = 4
|
|
225
|
+
layer?.borderWidth = 2
|
|
226
|
+
layer?.borderColor = NSColor.controlAccentColor.cgColor
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
required init?(coder: NSCoder) { fatalError() }
|
|
230
|
+
|
|
231
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
232
|
+
super.draw(dirtyRect)
|
|
233
|
+
let str = NSAttributedString(
|
|
234
|
+
string: "Press shortcut...",
|
|
235
|
+
attributes: [
|
|
236
|
+
.foregroundColor: NSColor.secondaryLabelColor,
|
|
237
|
+
.font: NSFont.systemFont(ofSize: 11),
|
|
238
|
+
]
|
|
239
|
+
)
|
|
240
|
+
let size = str.size()
|
|
241
|
+
let point = NSPoint(
|
|
242
|
+
x: (bounds.width - size.width) / 2,
|
|
243
|
+
y: (bounds.height - size.height) / 2
|
|
244
|
+
)
|
|
245
|
+
str.draw(at: point)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
override func keyDown(with event: NSEvent) {
|
|
249
|
+
// Escape cancels recording
|
|
250
|
+
if event.keyCode == 53 {
|
|
251
|
+
onCancel?()
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if let shortcut = StoredShortcut.from(event: event) {
|
|
256
|
+
onRecorded?(shortcut)
|
|
257
|
+
}
|
|
258
|
+
// If no valid shortcut (e.g. bare key press), ignore and keep recording
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
override func becomeFirstResponder() -> Bool {
|
|
262
|
+
layer?.borderColor = NSColor.controlAccentColor.cgColor
|
|
263
|
+
return true
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
override func resignFirstResponder() -> Bool {
|
|
267
|
+
layer?.borderColor = NSColor.separatorColor.cgColor
|
|
268
|
+
onCancel?()
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
}
|