native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #184 from supabitapp/sbertix/182-shortcuts-azery

authored by

khoi and committed by
GitHub
b203d97d da412c7b

+251 -59
+115 -26
supacode/App/AppShortcutOverride.swift
··· 62 62 63 63 extension AppShortcutOverride { 64 64 var displayString: String { 65 - displaySymbols.joined() 65 + Self.displaySymbols(for: keyCode, modifiers: modifiers).joined() 66 66 } 67 67 68 68 // Ordered array of individual display symbols: one per modifier, followed by the key. 69 69 var displaySymbols: [String] { 70 - displayModifierParts + [Self.displayCharacter(for: keyCode)] 70 + Self.displaySymbols(for: keyCode, modifiers: modifiers) 71 71 } 72 72 73 - private var displayModifierParts: [String] { 73 + static func displaySymbols(for keyCode: UInt16, modifiers: ModifierFlags) -> [String] { 74 74 var parts: [String] = [] 75 75 if modifiers.contains(.command) { parts.append("⌘") } 76 76 if modifiers.contains(.shift) { parts.append("⇧") } 77 77 if modifiers.contains(.option) { parts.append("⌥") } 78 78 if modifiers.contains(.control) { parts.append("⌃") } 79 + parts.append(displayCharacter(for: keyCode, modifiers: modifiers)) 79 80 return parts 80 81 } 81 82 } ··· 163 164 // Resolves the character for a key code using the current keyboard layout, 164 165 // falling back to US QWERTY when the layout is unavailable (e.g., CI, sandboxed contexts). 165 166 static func layoutCharacter(for code: UInt16) -> String? { 166 - if let char = currentLayoutCharacter(for: code) { return char } 167 + if let char = currentLayoutCharacter(for: code, modifierState: 0) { return char } 167 168 shortcutLogger.debug("Using US QWERTY fallback for key code \(code)") 168 169 return usQwertyFallback[code] 169 170 } 170 171 172 + static func displayCharacter(for keyEquivalent: KeyEquivalent) -> String { 173 + guard let code = keyCode(forDisplayedKeyEquivalent: keyEquivalent.character) else { 174 + return String(keyEquivalent.character).uppercased() 175 + } 176 + return displayCharacter(for: code) 177 + } 178 + 171 179 // The Ghostty key name for a given key code (e.g. "a", "arrow_up", "return"). 172 180 static func resolvedGhosttyKeyName(for code: UInt16) -> String { 173 181 ghosttyKeyName(for: code) 174 182 } 175 183 176 184 // Uses UCKeyTranslate to resolve the character from the active input source. 177 - private static func currentLayoutCharacter(for code: UInt16) -> String? { 178 - guard let inputSource = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), 179 - let layoutPtr = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData) 180 - else { 181 - return nil 182 - } 183 - let layoutData = unsafeBitCast(layoutPtr, to: CFData.self) 184 - guard CFGetTypeID(layoutData) == CFDataGetTypeID() else { 185 - shortcutLogger.warning("TIS property returned non-CFData type for key code \(code)") 185 + private static func currentLayoutCharacter(for code: UInt16, modifierState: UInt32) -> String? { 186 + guard let layoutData = currentKeyboardLayoutData() else { 186 187 return nil 187 188 } 188 189 guard let bytePtr = CFDataGetBytePtr(layoutData) else { ··· 197 198 keyboardLayout, 198 199 code, 199 200 UInt16(kUCKeyActionDisplay), 200 - 0, 201 + modifierState, 201 202 UInt32(LMGetKbdType()), 202 203 UInt32(kUCKeyTranslateNoDeadKeysBit), 203 204 &deadKeyState, ··· 220 221 } 221 222 } 222 223 224 + private static func currentKeyboardLayoutData() -> CFData? { 225 + let sources = currentKeyboardInputSources() 226 + for inputSource in sources { 227 + guard let layoutPtr = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData) else { 228 + continue 229 + } 230 + 231 + let layoutValue = unsafeBitCast(layoutPtr, to: CFTypeRef.self) 232 + guard CFGetTypeID(layoutValue) == CFDataGetTypeID() else { 233 + shortcutLogger.warning("TIS property returned non-CFData keyboard layout data.") 234 + continue 235 + } 236 + 237 + return unsafeDowncast(layoutValue, to: CFData.self) 238 + } 239 + 240 + if !sources.isEmpty { 241 + shortcutLogger.debug("No keyboard layout data found in \(sources.count) input source(s).") 242 + } 243 + return nil 244 + } 245 + 246 + // Tries progressively broader input sources. Non-Latin input methods may not 247 + // expose layout data on the primary source, so we fall through to the layout 248 + // and ASCII-capable sources. 249 + private static func currentKeyboardInputSources() -> [TISInputSource] { 250 + var sources: [TISInputSource] = [] 251 + if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() { 252 + sources.append(source) 253 + } 254 + if let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue() { 255 + sources.append(source) 256 + } 257 + if let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue() { 258 + sources.append(source) 259 + } 260 + if sources.isEmpty { 261 + shortcutLogger.warning("No keyboard input sources available; layout-aware display will use fallbacks.") 262 + } 263 + return sources 264 + } 265 + 266 + // AppKit renders menu key equivalents from the logical key equivalent. Reverse 267 + // lookup the active layout so our own labels match the menu bar. 268 + static func keyCode( 269 + forDisplayedKeyEquivalent character: Character, 270 + candidateKeyCodes: [UInt16] = candidatePrintableKeyCodes, 271 + modifierStates: [UInt32] = menuDisplayModifierStates, 272 + translatedCharacter: (UInt16, UInt32) -> String? 273 + ) -> UInt16? { 274 + let target = String(character).lowercased() 275 + for modifierState in modifierStates { 276 + for code in candidateKeyCodes { 277 + guard let resolved = translatedCharacter(code, modifierState) else { 278 + continue 279 + } 280 + guard resolved.lowercased() == target else { continue } 281 + return code 282 + } 283 + } 284 + return nil 285 + } 286 + 287 + static func keyCode(forDisplayedKeyEquivalent character: Character) -> UInt16? { 288 + keyCode(forDisplayedKeyEquivalent: character) { code, modifierState in 289 + currentLayoutCharacter(for: code, modifierState: modifierState) 290 + } 291 + } 292 + 223 293 // US QWERTY character mapping for environments without a keyboard layout. 224 294 private static let usQwertyFallback: [UInt16: String] = { 225 295 let entries: [(Int, String)] = [ ··· 243 313 return map 244 314 }() 245 315 316 + // UCKeyTranslate modifier states: unmodified, shift, option, shift+option. 317 + // Ordered so the simplest printable mapping is preferred during reverse lookup. 318 + private static let menuDisplayModifierStates: [UInt32] = [0, 0x02, 0x08, 0x0A] 319 + private static let candidatePrintableKeyCodes: [UInt16] = Array(usQwertyFallback.keys).sorted() 320 + 246 321 private static func ghosttyKeyName(for code: UInt16) -> String { 247 322 switch Int(code) { 248 323 case kVK_LeftArrow: "arrow_left" ··· 258 333 } 259 334 } 260 335 261 - private static func displayCharacter(for code: UInt16) -> String { 336 + static func displayCharacter(for code: UInt16, modifiers: ModifierFlags = []) -> String { 262 337 switch Int(code) { 263 - case kVK_LeftArrow: "←" 264 - case kVK_RightArrow: "→" 265 - case kVK_UpArrow: "↑" 266 - case kVK_DownArrow: "↓" 267 - case kVK_Return: "↩" 268 - case kVK_Escape: "⎋" 269 - case kVK_Delete: "⌫" 270 - case kVK_Tab: "⇥" 271 - case kVK_Space: "Space" 272 - default: layoutCharacter(for: code)?.uppercased() ?? String(format: "0x%02x", code) 338 + case kVK_LeftArrow: return "←" 339 + case kVK_RightArrow: return "→" 340 + case kVK_UpArrow: return "↑" 341 + case kVK_DownArrow: return "↓" 342 + case kVK_Return: return "↩" 343 + case kVK_Escape: return "⎋" 344 + case kVK_Delete: return "⌫" 345 + case kVK_Tab: return "⇥" 346 + case kVK_Space: return "Space" 347 + default: 348 + let modifierState = displayModifierState(for: modifiers) 349 + if let character = currentLayoutCharacter(for: code, modifierState: modifierState) { 350 + return character.uppercased() 351 + } 352 + return layoutCharacter(for: code)?.uppercased() ?? String(format: "0x%02x", code) 273 353 } 274 354 } 275 355 276 - private static func keyEquivalent(for code: UInt16) -> KeyEquivalent { 356 + static func keyEquivalent(for code: UInt16) -> KeyEquivalent { 277 357 switch Int(code) { 278 358 case kVK_LeftArrow: return .leftArrow 279 359 case kVK_RightArrow: return .rightArrow ··· 291 371 } 292 372 return KeyEquivalent(char) 293 373 } 374 + } 375 + 376 + // Only shift and option affect the printed character; command and control 377 + // do not alter UCKeyTranslate output. 378 + private static func displayModifierState(for modifiers: ModifierFlags) -> UInt32 { 379 + var state: UInt32 = 0 380 + if modifiers.contains(.shift) { state |= 0x02 } 381 + if modifiers.contains(.option) { state |= 0x08 } 382 + return state 294 383 } 295 384 }
+22 -22
supacode/App/AppShortcuts.swift
··· 115 115 116 116 // MARK: - Shortcut definition. 117 117 118 + private nonisolated let shortcutLogger = SupaLogger("Shortcuts") 119 + 118 120 struct AppShortcut: Identifiable { 119 121 let id: AppShortcutID 120 122 let keyEquivalent: KeyEquivalent ··· 124 126 125 127 init(id: AppShortcutID, key: Character, modifiers: EventModifiers) { 126 128 self.id = id 129 + self.keyEquivalent = KeyEquivalent(key) 127 130 self.modifiers = modifiers 128 - // Resolve the physical key code from the US QWERTY character to enable 129 - // layout-aware display and Ghostty keybind generation. 130 - let code = AppShortcutOverride.keyCode(for: key) 131 + let code = AppShortcutOverride.keyCode(forDisplayedKeyEquivalent: key) ?? AppShortcutOverride.keyCode(for: key) 131 132 self.keyCode = code 132 - if let code, let layoutChar = AppShortcutOverride.layoutCharacter(for: code) { 133 - self.keyEquivalent = KeyEquivalent(Character(layoutChar)) 134 - self.ghosttyKeyName = layoutChar.lowercased() 133 + if let code { 134 + self.ghosttyKeyName = AppShortcutOverride.resolvedGhosttyKeyName(for: code) 135 135 } else { 136 - self.keyEquivalent = KeyEquivalent(key) 136 + shortcutLogger.warning("No key code resolved for '\(key)'; Ghostty unbind may not work.") 137 137 self.ghosttyKeyName = String(key).lowercased() 138 138 } 139 139 } ··· 148 148 149 149 var displayName: String { id.displayName } 150 150 151 + var keyboardShortcut: KeyboardShortcut { 152 + KeyboardShortcut(keyEquivalent, modifiers: modifiers) 153 + } 154 + 151 155 var ghosttyKeybind: String { 152 156 let parts = ghosttyModifierParts + [ghosttyKeyName] 153 157 return parts.joined(separator: "+") ··· 163 167 } 164 168 165 169 var displaySymbols: [String] { 166 - displayModifierParts + [keyDisplay] 167 - } 168 - 169 - private var keyDisplay: String { 170 - if let keyCode, let layoutChar = AppShortcutOverride.layoutCharacter(for: keyCode) { 171 - layoutChar.uppercased() 172 - } else { 173 - keyEquivalent.display 170 + if let keyCode { 171 + return AppShortcutOverride.displaySymbols(for: keyCode, modifiers: rawModifierFlags) 174 172 } 173 + return keyboardShortcut.displaySymbols 175 174 } 176 175 177 176 // Resolves the effective shortcut considering user overrides. ··· 199 198 return parts 200 199 } 201 200 202 - private var displayModifierParts: [String] { 203 - var parts: [String] = [] 204 - if modifiers.contains(.command) { parts.append("⌘") } 205 - if modifiers.contains(.shift) { parts.append("⇧") } 206 - if modifiers.contains(.option) { parts.append("⌥") } 207 - if modifiers.contains(.control) { parts.append("⌃") } 208 - return parts 201 + private var rawModifierFlags: AppShortcutOverride.ModifierFlags { 202 + var flags: AppShortcutOverride.ModifierFlags = [] 203 + if modifiers.contains(.command) { flags.insert(.command) } 204 + if modifiers.contains(.option) { flags.insert(.option) } 205 + if modifiers.contains(.control) { flags.insert(.control) } 206 + if modifiers.contains(.shift) { flags.insert(.shift) } 207 + return flags 209 208 } 209 + 210 210 } 211 211 212 212 // MARK: - Category and grouping.
+7 -3
supacode/App/KeyboardShortcut+Display.swift
··· 1 1 import SwiftUI 2 2 3 3 extension KeyboardShortcut { 4 - var display: String { 4 + var displaySymbols: [String] { 5 5 var parts: [String] = [] 6 6 if modifiers.contains(.command) { parts.append("⌘") } 7 7 if modifiers.contains(.shift) { parts.append("⇧") } 8 8 if modifiers.contains(.option) { parts.append("⌥") } 9 9 if modifiers.contains(.control) { parts.append("⌃") } 10 10 parts.append(key.display) 11 - return parts.joined() 11 + return parts 12 + } 13 + 14 + var display: String { 15 + displaySymbols.joined() 12 16 } 13 17 } 14 18 ··· 28 32 case .end: "↘" 29 33 case .pageUp: "⇞" 30 34 case .pageDown: "⇟" 31 - default: String(character).uppercased() 35 + default: AppShortcutOverride.displayCharacter(for: self) 32 36 } 33 37 } 34 38 }
+14 -7
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 1296 1296 } 1297 1297 1298 1298 private func keyboardLayoutId() -> String? { 1299 - guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue() else { 1300 - return nil 1301 - } 1302 - guard let raw = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { 1303 - return nil 1299 + let sources = [ 1300 + TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), 1301 + TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), 1302 + TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue(), 1303 + ] 1304 + 1305 + for source in sources.compactMap({ $0 }) { 1306 + guard let raw = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { 1307 + continue 1308 + } 1309 + let value = Unmanaged<CFString>.fromOpaque(raw).takeUnretainedValue() 1310 + return value as String 1304 1311 } 1305 - let value = Unmanaged<CFString>.fromOpaque(raw).takeUnretainedValue() 1306 - return value as String 1312 + 1313 + return nil 1307 1314 } 1308 1315 1309 1316 private func sendMousePosition(_ event: NSEvent) {
+93 -1
supacodeTests/AppShortcutOverrideTests.swift
··· 109 109 #expect(override.eventModifiers == original) 110 110 } 111 111 112 + @Test func reverseLookupPrefersMenuKeyForShiftedCharacter() { 113 + let resolved = AppShortcutOverride.keyCode( 114 + forDisplayedKeyEquivalent: "\"", 115 + candidateKeyCodes: [19, 20], 116 + translatedCharacter: { code, modifierState in 117 + switch (code, modifierState) { 118 + case (19, 0): "e" 119 + case (19, 0x02): "2" 120 + case (20, 0): "%" 121 + case (20, 0x02): "\"" 122 + default: nil 123 + } 124 + } 125 + ) 126 + 127 + #expect(resolved == 20) 128 + } 129 + 130 + @Test func reverseLookupReturnsNilWhenNoMatch() { 131 + let resolved = AppShortcutOverride.keyCode( 132 + forDisplayedKeyEquivalent: "€", 133 + candidateKeyCodes: [19, 20], 134 + translatedCharacter: { code, modifierState in 135 + switch (code, modifierState) { 136 + case (19, 0): "e" 137 + case (19, 0x02): "E" 138 + case (20, 0): "3" 139 + case (20, 0x02): "#" 140 + default: nil 141 + } 142 + } 143 + ) 144 + 145 + #expect(resolved == nil) 146 + } 147 + 148 + @Test func reverseLookupPrefersUnshiftedOverShifted() { 149 + // "2" is unshifted on key 21 and shifted on key 19. Unshifted should win. 150 + let resolved = AppShortcutOverride.keyCode( 151 + forDisplayedKeyEquivalent: "2", 152 + candidateKeyCodes: [19, 21], 153 + modifierStates: [0, 0x02], 154 + translatedCharacter: { code, modifierState in 155 + switch (code, modifierState) { 156 + case (19, 0): "é" 157 + case (19, 0x02): "2" 158 + case (21, 0): "2" 159 + case (21, 0x02): "\"" 160 + default: nil 161 + } 162 + } 163 + ) 164 + 165 + #expect(resolved == 21) 166 + } 167 + 168 + @Test func reverseLookupFindsOptionLayerCharacter() { 169 + let resolved = AppShortcutOverride.keyCode( 170 + forDisplayedKeyEquivalent: "€", 171 + candidateKeyCodes: [19, 20], 172 + modifierStates: [0, 0x02, 0x08, 0x0A], 173 + translatedCharacter: { code, modifierState in 174 + switch (code, modifierState) { 175 + case (19, 0): "e" 176 + case (19, 0x02): "E" 177 + case (19, 0x08): "€" 178 + case (20, 0): "3" 179 + case (20, 0x02): "#" 180 + default: nil 181 + } 182 + } 183 + ) 184 + 185 + #expect(resolved == 19) 186 + } 187 + 188 + @Test func reverseLookupIsCaseInsensitive() { 189 + let resolved = AppShortcutOverride.keyCode( 190 + forDisplayedKeyEquivalent: "A", 191 + candidateKeyCodes: [0], 192 + translatedCharacter: { code, modifierState in 193 + switch (code, modifierState) { 194 + case (0, 0): "a" 195 + default: nil 196 + } 197 + } 198 + ) 199 + 200 + #expect(resolved == 0) 201 + } 202 + 112 203 // MARK: - Disabled sentinel. 113 204 114 205 @Test func disabledSentinel() { ··· 175 266 @Test func displayStringAllModifiers() { 176 267 let code = UInt16(kVK_ANSI_A) 177 268 let override = AppShortcutOverride(keyCode: code, modifiers: [.command, .shift, .option, .control]) 178 - let char = AppShortcutOverride.layoutCharacter(for: code)!.uppercased() 269 + let char = AppShortcutOverride.displayCharacter(for: code, modifiers: [.shift, .option]) 179 270 #expect(override.displayString == "⌘⇧⌥⌃\(char)") 180 271 } 181 272 ··· 212 303 #expect(enabled != disabled) 213 304 #expect(enabled.hashValue != disabled.hashValue) 214 305 } 306 + 215 307 }