diff --git a/Leader Key.xcodeproj/project.pbxproj b/Leader Key.xcodeproj/project.pbxproj index 19ed5c7..c7331a1 100644 --- a/Leader Key.xcodeproj/project.pbxproj +++ b/Leader Key.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 115AA5BF2DA521C600C17E18 /* ActionIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 115AA5BE2DA521C200C17E18 /* ActionIcon.swift */; }; + 6D9B9C042DBA000000000002 /* KeyCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9B9C032DBA000000000002 /* KeyCapture.swift */; }; + 6D9B9C062DBA000000000003 /* ConfigEditorShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9B9C052DBA000000000003 /* ConfigEditorShared.swift */; }; 115AA5C22DA546D500C17E18 /* SymbolPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 115AA5C12DA546D500C17E18 /* SymbolPicker */; }; 130196C62D73B3DE0093148B /* Breadcrumbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130196C52D73B3DC0093148B /* Breadcrumbs.swift */; }; 423632222D68CA6500878D92 /* MysteryBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423632212D68CA6500878D92 /* MysteryBox.swift */; }; @@ -106,6 +108,8 @@ 605385A22D523CAD00BEDB4B /* Pulsate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pulsate.swift; sourceTree = ""; }; 606C56EE2DAB875A00198B9F /* Cheater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cheater.swift; sourceTree = ""; }; 6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigOutlineEditorView.swift; sourceTree = ""; }; + 6D9B9C032DBA000000000002 /* KeyCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCapture.swift; sourceTree = ""; }; + 6D9B9C052DBA000000000003 /* ConfigEditorShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigEditorShared.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -241,6 +245,8 @@ 6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */, 427C18372BD3262100955B98 /* VisualEffectBackground.swift */, 42F4CDCC2D45B13600D0DD76 /* KeyButton.swift */, + 6D9B9C032DBA000000000002 /* KeyCapture.swift */, + 6D9B9C052DBA000000000003 /* ConfigEditorShared.swift */, 115AA5BE2DA521C200C17E18 /* ActionIcon.swift */, ); path = Views; @@ -420,6 +426,8 @@ 427C18282BD31E2E00955B98 /* GeneralPane.swift in Sources */, 423632282D6A806700878D92 /* Theme.swift in Sources */, 6D9B9C012DBA000000000001 /* ConfigOutlineEditorView.swift in Sources */, + 6D9B9C042DBA000000000002 /* KeyCapture.swift in Sources */, + 6D9B9C062DBA000000000003 /* ConfigEditorShared.swift in Sources */, 42F4CDD12D48C52400D0DD76 /* Extensions.swift in Sources */, 427C182F2BD3206200955B98 /* UserState.swift in Sources */, 427C18202BD31C3D00955B98 /* AppDelegate.swift in Sources */, diff --git a/Leader Key/AppDelegate.swift b/Leader Key/AppDelegate.swift index c0e405f..68190e6 100644 --- a/Leader Key/AppDelegate.swift +++ b/Leader Key/AppDelegate.swift @@ -36,7 +36,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, AdvancedPane().environmentObject(self.config) }), ], - style: .segmentedControl + style: .segmentedControl, ) func applicationDidFinishLaunching(_: Notification) { @@ -147,7 +147,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, // MARK: - Sparkle Gentle Reminders - var supportsGentleScheduledUpdateReminders: Bool { + @objc var supportsGentleScheduledUpdateReminders: Bool { return true } diff --git a/Leader Key/Controller.swift b/Leader Key/Controller.swift index 8843b6f..a628537 100644 --- a/Leader Key/Controller.swift +++ b/Leader Key/Controller.swift @@ -220,7 +220,21 @@ class Controller { return englishGlyph(for: event) } - // 2. Use the system-translated character first. + // 2. For special keys like Enter, always use the mapped glyph + if let entry = KeyMaps.entry(for: event.keyCode) { + // For Enter, Space, Tab, arrows, etc. - use the glyph representation + if event.keyCode == KeyHelpers.enter.rawValue || event.keyCode == KeyHelpers.space.rawValue + || event.keyCode == KeyHelpers.tab.rawValue + || event.keyCode == KeyHelpers.leftArrow.rawValue + || event.keyCode == KeyHelpers.rightArrow.rawValue + || event.keyCode == KeyHelpers.upArrow.rawValue + || event.keyCode == KeyHelpers.downArrow.rawValue + { + return entry.glyph + } + } + + // 3. Use the system-translated character for regular keys. if let printable = event.charactersIgnoringModifiers, !printable.isEmpty, printable.unicodeScalars.first?.isASCII ?? false @@ -228,7 +242,7 @@ class Controller { return printable // already contains correct case } - // 3. For arrows, ␣, ⌫ … use map as last resort. + // 4. For arrows, ␣, ⌫ … use map as last resort. return englishGlyph(for: event) } diff --git a/Leader Key/Views/ConfigEditorShared.swift b/Leader Key/Views/ConfigEditorShared.swift new file mode 100644 index 0000000..2da726f --- /dev/null +++ b/Leader Key/Views/ConfigEditorShared.swift @@ -0,0 +1,141 @@ +import AppKit +import ObjectiveC + +enum ConfigEditorUI { + static func setButtonTitle(_ button: NSButton, text: String, placeholder: Bool) { + let attr = NSMutableAttributedString(string: text) + let color: NSColor = placeholder ? .secondaryLabelColor : .labelColor + attr.addAttribute( + .foregroundColor, value: color, range: NSRange(location: 0, length: attr.length)) + button.title = text + button.attributedTitle = attr + } + + static func presentMoreMenu( + anchor: NSView?, + onDuplicate: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + guard let anchor else { return } + let menu = NSMenu() + menu.addItem( + withTitle: "Duplicate", + action: #selector(MenuHandler.duplicate), + keyEquivalent: "" + ) + menu.addItem( + withTitle: "Delete", + action: #selector(MenuHandler.delete), + keyEquivalent: "" + ) + let handler = MenuHandler(onDuplicate: onDuplicate, onDelete: onDelete) + for item in menu.items { item.target = handler } + objc_setAssociatedObject( + menu, + &handlerAssociationKey, + handler, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + let point = NSPoint(x: 0, y: anchor.bounds.height) + menu.popUp(positioning: nil, at: point, in: anchor) + } + + static func presentIconMenu( + anchor: NSView?, + onPickAppIcon: @escaping () -> Void, + onPickSymbol: @escaping () -> Void, + onClear: @escaping () -> Void + ) { + guard let anchor else { return } + let menu = NSMenu() + menu.addItem( + withTitle: "App Icon…", + action: #selector(MenuHandler.pickAppIcon), + keyEquivalent: "" + ) + menu.addItem( + withTitle: "Symbol…", + action: #selector(MenuHandler.pickSymbol), + keyEquivalent: "" + ) + menu.addItem(NSMenuItem.separator()) + menu.addItem(withTitle: "Clear", action: #selector(MenuHandler.clearIcon), keyEquivalent: "") + let handler = MenuHandler( + onPickAppIcon: onPickAppIcon, + onPickSymbol: onPickSymbol, + onClearIcon: onClear, + onDuplicate: {}, + onDelete: {} + ) + for item in menu.items { item.target = handler } + objc_setAssociatedObject( + menu, + &handlerAssociationKey, + handler, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + let point = NSPoint(x: 0, y: anchor.bounds.height) + menu.popUp(positioning: nil, at: point, in: anchor) + } + + private static var handlerAssociationKey: UInt8 = 0 + + private final class MenuHandler: NSObject { + let onPickAppIcon: (() -> Void)? + let onPickSymbol: (() -> Void)? + let onClearIcon: (() -> Void)? + let onDuplicate: () -> Void + let onDelete: () -> Void + + init( + onPickAppIcon: (() -> Void)? = nil, + onPickSymbol: (() -> Void)? = nil, + onClearIcon: (() -> Void)? = nil, + onDuplicate: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.onPickAppIcon = onPickAppIcon + self.onPickSymbol = onPickSymbol + self.onClearIcon = onClearIcon + self.onDuplicate = onDuplicate + self.onDelete = onDelete + } + + @objc func pickAppIcon() { onPickAppIcon?() } + @objc func pickSymbol() { onPickSymbol?() } + @objc func clearIcon() { onClearIcon?() } + @objc func duplicate() { onDuplicate() } + @objc func delete() { onDelete() } + } +} + +extension Action { + func resolvedIcon() -> NSImage? { + if let iconPath = iconPath, !iconPath.isEmpty { + if iconPath.hasSuffix(".app") { return NSWorkspace.shared.icon(forFile: iconPath) } + if let img = NSImage(systemSymbolName: iconPath, accessibilityDescription: nil) { return img } + } + switch type { + case .application: + return NSWorkspace.shared.icon(forFile: value) + case .url: + return NSImage(systemSymbolName: "link", accessibilityDescription: nil) + case .command: + return NSImage(systemSymbolName: "terminal", accessibilityDescription: nil) + case .folder: + return NSImage(systemSymbolName: "folder", accessibilityDescription: nil) + default: + return NSImage(systemSymbolName: "questionmark", accessibilityDescription: nil) + } + } +} + +extension Group { + func resolvedIcon() -> NSImage? { + if let iconPath = iconPath, !iconPath.isEmpty { + if iconPath.hasSuffix(".app") { return NSWorkspace.shared.icon(forFile: iconPath) } + if let img = NSImage(systemSymbolName: iconPath, accessibilityDescription: nil) { return img } + } + return NSImage(systemSymbolName: "folder", accessibilityDescription: nil) + } +} diff --git a/Leader Key/Views/ConfigOutlineEditorView.swift b/Leader Key/Views/ConfigOutlineEditorView.swift index 365bfff..5838cc5 100644 --- a/Leader Key/Views/ConfigOutlineEditorView.swift +++ b/Leader Key/Views/ConfigOutlineEditorView.swift @@ -538,8 +538,6 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { private func setup() { wantsLayer = true - wantsLayer = true - let container = NSStackView() container.orientation = .horizontal container.spacing = 8 @@ -630,25 +628,21 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { } private func showMoreMenu(anchor: NSView?) { - guard let anchor else { return } - let menu = NSMenu() - menu.addItem(withTitle: "Duplicate", action: #selector(handleDuplicate), keyEquivalent: "") - menu.addItem(withTitle: "Delete", action: #selector(handleDelete), keyEquivalent: "") - for item in menu.items { item.target = self } - let point = NSPoint(x: 0, y: anchor.bounds.height) - menu.popUp(positioning: nil, at: point, in: anchor) + ConfigEditorUI.presentMoreMenu( + anchor: anchor, + onDuplicate: { self.onDuplicate?() }, + onDelete: { self.onDelete?() } + ) } - @objc private func handleDuplicate() { onDuplicate?() } - @objc private func handleDelete() { onDelete?() } - private func updateButtons(for action: Action) { keyButton.title = (action.key?.isEmpty ?? true) ? "Key" : (KeyMaps.glyph(for: action.key ?? "") ?? action.key ?? "Key") let isPlaceholder = (action.label?.isEmpty ?? true) - setButtonTitle( - labelButton, text: isPlaceholder ? "Label" : (action.label ?? "Label"), + ConfigEditorUI.setButtonTitle( + labelButton, + text: isPlaceholder ? "Label" : (action.label ?? "Label"), placeholder: isPlaceholder) } @@ -682,66 +676,34 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { private func rebuildValue(for action: Action) { while let v = valueStack.arrangedSubviews.first { v.removeFromSuperview() } - switch action.type { - case .application: + let descriptor = ValueDescriptor.forAction(action) + switch descriptor.kind { + case .picker(let picker): let choose = Self.chooseButton( - title: "Choose…", chooseDir: false, allowedTypes: [.application, .applicationBundle], + title: picker.buttonTitle, + chooseDir: picker.chooseDirectories, + allowedTypes: picker.allowedTypes, width: Layout.chooserWidth ) { [weak self] url in guard var a = self?.currentAction() else { return } a.value = url.path self?.onChange?(.action(a)) + self?.rebuildValue(for: a) + self?.updateIcon(for: a) } - let label = NSTextField(labelWithString: action.value) - label.lineBreakMode = .byTruncatingMiddle - label.controlSize = .regular - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - do { // Target width without forcing conflicts - let c = label.widthAnchor.constraint(lessThanOrEqualToConstant: Layout.valueWidth) - c.priority = .defaultHigh - c.isActive = true - } - for v in [choose, label] { valueStack.addArrangedSubview(v) } - case .folder: - let choose = Self.chooseButton( - title: "Choose…", chooseDir: true, allowedTypes: nil, width: Layout.chooserWidth - ) { [weak self] url in - guard var a = self?.currentAction() else { return } - a.value = url.path - self?.onChange?(.action(a)) - } - let label = NSTextField(labelWithString: action.value) - label.lineBreakMode = .byTruncatingMiddle - label.controlSize = .regular - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - do { - let c = label.widthAnchor.constraint(lessThanOrEqualToConstant: Layout.valueWidth) - c.priority = .defaultHigh - c.isActive = true - } + let label = Self.valueLabel(text: descriptor.display) for v in [choose, label] { valueStack.addArrangedSubview(v) } - default: - let edit = NSButton(title: "Edit…", target: nil, action: nil) - edit.controlSize = .regular - edit.bezelStyle = .rounded - edit.widthAnchor.constraint(equalToConstant: Layout.chooserWidth).isActive = true - let preview = NSTextField(labelWithString: action.value) - preview.lineBreakMode = .byTruncatingMiddle - preview.controlSize = .regular - preview.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - do { - let c = preview.widthAnchor.constraint(lessThanOrEqualToConstant: Layout.valueWidth) - c.priority = .defaultHigh - c.isActive = true - } + case .prompt(let promptTitle): + let edit = Self.editButton(width: Layout.chooserWidth) edit.targetClosure { [weak self] in - self?.promptText(title: "Value", initial: action.value) { text in + self?.promptText(title: promptTitle, initial: action.value) { text in guard var a = self?.currentAction() else { return } a.value = text self?.onChange?(.action(a)) self?.rebuildValue(for: a) } } + let preview = Self.valueLabel(text: descriptor.display) for v in [edit, preview] { valueStack.addArrangedSubview(v) } } } @@ -752,6 +714,65 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { rebuildValue(for: action) // ensure value UI matches type after change } + private struct ValueDescriptor { + struct PickerConfig { + let buttonTitle: String + let chooseDirectories: Bool + let allowedTypes: [UTType]? + } + enum Kind { + case picker(PickerConfig) + case prompt(String) + } + + let kind: Kind + let display: String + + static func forAction(_ action: Action) -> ValueDescriptor { + switch action.type { + case .application: + let config = PickerConfig( + buttonTitle: "Choose…", + chooseDirectories: false, + allowedTypes: [.application, .applicationBundle] + ) + return ValueDescriptor(kind: .picker(config), display: action.value) + case .folder: + let config = PickerConfig( + buttonTitle: "Choose…", + chooseDirectories: true, + allowedTypes: nil + ) + return ValueDescriptor(kind: .picker(config), display: action.value) + case .command: + return ValueDescriptor(kind: .prompt("Command"), display: action.value) + case .url: + return ValueDescriptor(kind: .prompt("URL"), display: action.value) + default: + return ValueDescriptor(kind: .prompt("Value"), display: action.value) + } + } + } + + private static func valueLabel(text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.lineBreakMode = .byTruncatingMiddle + label.controlSize = .regular + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let constraint = label.widthAnchor.constraint(lessThanOrEqualToConstant: Layout.valueWidth) + constraint.priority = .defaultHigh + constraint.isActive = true + return label + } + + private static func editButton(width: CGFloat) -> NSButton { + let button = NSButton(title: "Edit…", target: nil, action: nil) + button.controlSize = .regular + button.bezelStyle = .rounded + button.widthAnchor.constraint(equalToConstant: width).isActive = true + return button + } + private func currentAction() -> Action? { guard case .action(var a) = node?.kind else { return nil } let keyTitle = keyButton.title @@ -773,8 +794,10 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { b.targetClosure { let panel = NSOpenPanel() panel.allowsMultipleSelection = false - panel.canChooseDirectories = chooseDir - panel.canChooseFiles = !chooseDir + let allowsAppBundles = allowsAppBundles(allowedTypes) + panel.treatsFilePackagesAsDirectories = false + panel.canChooseDirectories = chooseDir || allowsAppBundles + panel.canChooseFiles = !chooseDir || allowsAppBundles if let types = allowedTypes { panel.allowedContentTypes = types } panel.directoryURL = chooseDir @@ -784,6 +807,13 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { return b } + private static func allowsAppBundles(_ types: [UTType]?) -> Bool { + guard let types else { return false } + return types.contains { type in + type == .application || type == .applicationBundle + } + } + private static func index(for type: Type) -> Int { switch type { case .application: return 0 @@ -824,22 +854,13 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self = self else { return event } - if event.keyCode == 53 { // Escape cancels - self.endKeyCapture(set: nil) - return nil - } - // Use KeyMap system for consistent key representation - if let entry = KeyMaps.entry(for: event.keyCode) { - self.endKeyCapture(set: entry.glyph) - return nil - } - // Fallback for unmapped keys - let chars = event.charactersIgnoringModifiers ?? event.characters ?? "" - if let ch = chars.first { - self.endKeyCapture(set: String(ch)) - return nil - } - return event + let handled = KeyCapture.handle( + event: event, + onSet: { self.endKeyCapture(set: $0) }, + onCancel: { self.endKeyCapture(set: nil) }, + onClear: { self.endKeyCapture(set: nil) } + ) + return handled ? nil : event } } @@ -857,7 +878,8 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { keyButton.title = "Key" return } - a.key = char + let normalized = char?.isEmpty == true ? nil : char + a.key = normalized onChange?(.action(a)) updateButtons(for: a) @@ -867,15 +889,12 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { // MARK: Icon helpers (action) private func showIconMenu(anchor: NSView?) { - guard let anchor else { return } - let menu = NSMenu() - menu.addItem(withTitle: "App Icon…", action: #selector(handlePickAppIcon), keyEquivalent: "") - menu.addItem(withTitle: "Symbol…", action: #selector(handlePickSymbol), keyEquivalent: "") - menu.addItem(NSMenuItem.separator()) - menu.addItem(withTitle: "Clear", action: #selector(handleClearIcon), keyEquivalent: "") - for item in menu.items { item.target = self } - let point = NSPoint(x: 0, y: anchor.bounds.height) - menu.popUp(positioning: nil, at: point, in: anchor) + ConfigEditorUI.presentIconMenu( + anchor: anchor, + onPickAppIcon: { self.handlePickAppIcon() }, + onPickSymbol: { self.handlePickSymbol() }, + onClear: { self.handleClearIcon() } + ) } @objc private func handlePickAppIcon() { @@ -924,36 +943,7 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { } private func updateIcon(for action: Action) { - iconButton.image = Self.iconImage(for: action) - } - - private static func iconImage(for action: Action) -> NSImage? { - if let iconPath = action.iconPath, !iconPath.isEmpty { - if iconPath.hasSuffix(".app") { return NSWorkspace.shared.icon(forFile: iconPath) } - if let img = NSImage(systemSymbolName: iconPath, accessibilityDescription: nil) { return img } - } - switch action.type { - case .application: - return NSWorkspace.shared.icon(forFile: action.value) - case .url: - return NSImage(systemSymbolName: "link", accessibilityDescription: nil) - case .command: - return NSImage(systemSymbolName: "terminal", accessibilityDescription: nil) - case .folder: - return NSImage(systemSymbolName: "folder", accessibilityDescription: nil) - default: - return NSImage(systemSymbolName: "questionmark", accessibilityDescription: nil) - } - } - - private func setButtonTitle(_ button: NSButton, text: String, placeholder: Bool) { - let attr = NSMutableAttributedString(string: text) - let color: NSColor = placeholder ? .secondaryLabelColor : .labelColor - attr.addAttribute( - .foregroundColor, value: color, range: NSRange(location: 0, length: attr.length)) - // Set plain title first so .title stays in sync for reads, then apply color - button.title = text - button.attributedTitle = attr + iconButton.image = action.resolvedIcon() } // symbol picker presenting delegated to shared helper @@ -1097,8 +1087,9 @@ private class GroupCellView: NSTableCellView, NSWindowDelegate { (group.key?.isEmpty ?? true) ? "Group Key" : (KeyMaps.glyph(for: group.key ?? "") ?? group.key ?? "Group Key") let isPlaceholder = (group.label?.isEmpty ?? true) - setButtonTitle( - labelButton, text: isPlaceholder ? "Label" : (group.label ?? "Label"), + ConfigEditorUI.setButtonTitle( + labelButton, + text: isPlaceholder ? "Label" : (group.label ?? "Label"), placeholder: isPlaceholder) updateGlobalShortcutView(for: group) } @@ -1195,22 +1186,13 @@ private class GroupCellView: NSTableCellView, NSWindowDelegate { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self = self else { return event } - if event.keyCode == 53 { - self.endKeyCapture(set: nil) - return nil - } - // Use KeyMap system for consistent key representation - if let entry = KeyMaps.entry(for: event.keyCode) { - self.endKeyCapture(set: entry.glyph) - return nil - } - // Fallback for unmapped keys - let chars = event.charactersIgnoringModifiers ?? event.characters ?? "" - if let ch = chars.first { - self.endKeyCapture(set: String(ch)) - return nil - } - return event + let handled = KeyCapture.handle( + event: event, + onSet: { self.endKeyCapture(set: $0) }, + onCancel: { self.endKeyCapture(set: nil) }, + onClear: { self.endKeyCapture(set: nil) } + ) + return handled ? nil : event } } @@ -1229,15 +1211,17 @@ private class GroupCellView: NSTableCellView, NSWindowDelegate { return } + let normalized = char?.isEmpty == true ? nil : char + // If key changed and there was a global shortcut, remove it - if let oldKey = g.key, !oldKey.isEmpty, char != oldKey { + if let oldKey = g.key, !oldKey.isEmpty, normalized != oldKey { var shortcuts = Defaults[.groupShortcuts] shortcuts.remove(oldKey) Defaults[.groupShortcuts] = shortcuts KeyboardShortcuts.reset([KeyboardShortcuts.Name("group-\(oldKey)")]) } - g.key = char + g.key = normalized onChange?(.group(g)) updateButtons(for: g) @@ -1251,15 +1235,12 @@ private class GroupCellView: NSTableCellView, NSWindowDelegate { // MARK: Icon helpers (group) private func showIconMenu(anchor: NSView?) { - guard let anchor else { return } - let menu = NSMenu() - menu.addItem(withTitle: "App Icon…", action: #selector(handlePickAppIcon), keyEquivalent: "") - menu.addItem(withTitle: "Symbol…", action: #selector(handlePickSymbol), keyEquivalent: "") - menu.addItem(NSMenuItem.separator()) - menu.addItem(withTitle: "Clear", action: #selector(handleClearIcon), keyEquivalent: "") - for item in menu.items { item.target = self } - let point = NSPoint(x: 0, y: anchor.bounds.height) - menu.popUp(positioning: nil, at: point, in: anchor) + ConfigEditorUI.presentIconMenu( + anchor: anchor, + onPickAppIcon: { self.handlePickAppIcon() }, + onPickSymbol: { self.handlePickSymbol() }, + onClear: { self.handleClearIcon() } + ) } @objc private func handlePickAppIcon() { @@ -1308,41 +1289,16 @@ private class GroupCellView: NSTableCellView, NSWindowDelegate { } private func updateIcon(for group: Group) { - iconButton.image = Self.iconImage(for: group) - } - - private static func iconImage(for group: Group) -> NSImage? { - if let iconPath = group.iconPath, !iconPath.isEmpty { - if iconPath.hasSuffix(".app") { return NSWorkspace.shared.icon(forFile: iconPath) } - if let img = NSImage(systemSymbolName: iconPath, accessibilityDescription: nil) { return img } - } - return NSImage(systemSymbolName: "folder", accessibilityDescription: nil) + iconButton.image = group.resolvedIcon() } - private func setButtonTitle(_ button: NSButton, text: String, placeholder: Bool) { - let attr = NSMutableAttributedString(string: text) - let color: NSColor = placeholder ? .secondaryLabelColor : .labelColor - attr.addAttribute( - .foregroundColor, value: color, range: NSRange(location: 0, length: attr.length)) - // Keep .title updated for logic that reads it; apply visual dim via attributedTitle - button.title = text - button.attributedTitle = attr - } - - // symbol picker presenting delegated to shared helper - private func showMoreMenu(anchor: NSView?) { - guard let anchor else { return } - let menu = NSMenu() - menu.addItem(withTitle: "Duplicate", action: #selector(handleDuplicate), keyEquivalent: "") - menu.addItem(withTitle: "Delete", action: #selector(handleDelete), keyEquivalent: "") - for item in menu.items { item.target = self } - let point = NSPoint(x: 0, y: anchor.bounds.height) - menu.popUp(positioning: nil, at: point, in: anchor) + ConfigEditorUI.presentMoreMenu( + anchor: anchor, + onDuplicate: { self.onDuplicate?() }, + onDelete: { self.onDelete?() } + ) } - - @objc private func handleDuplicate() { onDuplicate?() } - @objc private func handleDelete() { onDelete?() } } // MARK: - Editor Node diff --git a/Leader Key/Views/KeyButton.swift b/Leader Key/Views/KeyButton.swift index 3079119..e741b6d 100644 --- a/Leader Key/Views/KeyButton.swift +++ b/Leader Key/Views/KeyButton.swift @@ -93,41 +93,26 @@ struct KeyListenerView: NSViewRepresentable { override var acceptsFirstResponder: Bool { true } - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - } - override func keyDown(with event: NSEvent) { guard let isListening = isListening, let text = text, isListening.wrappedValue else { super.keyDown(with: event) return } - // Handle special behaviors first - switch event.keyCode { - case 53: // Escape key - restore old value - if let oldValue = oldValue { - text.wrappedValue = oldValue.wrappedValue - } - return - case 51, 117: // Backspace or Delete - clear field - text.wrappedValue = "" - return - default: - break - } + let handled = KeyCapture.handle( + event: event, + onSet: { value in text.wrappedValue = value ?? "" }, + onCancel: { if let oldValue = self.oldValue { text.wrappedValue = oldValue.wrappedValue } }, + onClear: { text.wrappedValue = "" } + ) - // Use centralized key mapping for all other keys - if let entry = KeyMaps.entry(for: event.keyCode) { - text.wrappedValue = entry.glyph - } else if let characters = event.characters, !characters.isEmpty { - // Fallback for unmapped keys - text.wrappedValue = String(characters.first!) - } - - DispatchQueue.main.async { - isListening.wrappedValue = false - self.onKeyChanged?(self.oldValue?.wrappedValue, self.text?.wrappedValue) + if handled { + DispatchQueue.main.async { + isListening.wrappedValue = false + self.onKeyChanged?(self.oldValue?.wrappedValue, self.text?.wrappedValue) + } + } else { + super.keyDown(with: event) } } diff --git a/Leader Key/Views/KeyCapture.swift b/Leader Key/Views/KeyCapture.swift new file mode 100644 index 0000000..cbad152 --- /dev/null +++ b/Leader Key/Views/KeyCapture.swift @@ -0,0 +1,67 @@ +import AppKit + +/// Shared key capture utilities so SwiftUI and AppKit inputs stay in sync. +enum KeyCapture { + enum Translation { + case cancel + case clear + case set(String) + case ignore + } + + static func translate(event: NSEvent) -> Translation { + switch event.keyCode { + case 53: + return .cancel + case 51, 117: + return .clear + default: + break + } + + if let characters = event.characters, characters.count == 1 { + if let character = characters.first, character.isLetter { + return .set(String(character)) + } + } + + if let entry = KeyMaps.entry(for: event.keyCode) { + return .set(entry.glyph) + } + + if let characters = event.charactersIgnoringModifiers ?? event.characters { + if let first = characters.first { + return .set(String(first)) + } + } + + return .ignore + } + + /// Processes an event and invokes the appropriate callbacks. + /// - Returns: `true` when the event was handled. + static func handle( + event: NSEvent, + onSet: (String?) -> Void, + onCancel: () -> Void, + onClear: (() -> Void)? = nil + ) -> Bool { + switch translate(event: event) { + case .cancel: + onCancel() + return true + case .clear: + if let onClear { + onClear() + } else { + onSet("") + } + return true + case .set(let value): + onSet(value) + return true + case .ignore: + return false + } + } +} diff --git a/Leader KeyTests/KeyboardLayoutTests.swift b/Leader KeyTests/KeyboardLayoutTests.swift index a00520d..9c898b0 100644 --- a/Leader KeyTests/KeyboardLayoutTests.swift +++ b/Leader KeyTests/KeyboardLayoutTests.swift @@ -13,7 +13,7 @@ class KeyboardLayoutTests: XCTestCase { override func setUp() { super.setUp() cancellables = Set() - + // Create test instances userConfig = UserConfig() userState = UserState(userConfig: userConfig)