From 6805b182e40e556c9fb4f2fcbe7c8151dc3ad9fc Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Sun, 14 Dec 2025 15:07:20 -0800 Subject: [PATCH 1/9] feat: add visual keyboard layout editor and cheatsheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a visual keyboard interface for both editing and viewing shortcuts: - Keyboard Editor: A new Settings tab that displays a full QWERTY keyboard. Click any key to add or edit actions and groups directly on the layout. - Keyboard Cheatsheet: An alternative to the list-style cheatsheet that shows your bindings overlaid on a keyboard. Toggle between styles in preferences. Technical Details: - New KeyboardLayout module: KeyboardLayoutModel, KeyboardLayoutView, KeyView - KeyboardCheatsheetView for overlay display of bindings - KeyboardPane settings tab with unified editor for actions and groups - Modifier key monitoring in Controller for real-time shift state - Cheatsheet style preference stored in UserDefaults - Extracted reusable components: KeyBadge, CheatsheetRows 🤖 Generated with assistance from [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Leader Key.xcodeproj/project.pbxproj | 44 + Leader Key/AppDelegate.swift | 7 + Leader Key/Cheatsheet.swift | 240 ++--- Leader Key/Cheatsheet/CheatsheetRows.swift | 84 ++ Leader Key/Cheatsheet/KeyBadge.swift | 16 + Leader Key/Controller.swift | 142 ++- Leader Key/Defaults.swift | 7 + Leader Key/KeyboardLayout/KeyView.swift | 163 ++++ .../KeyboardCheatsheetView.swift | 73 ++ .../KeyboardLayout/KeyboardLayoutModel.swift | 174 ++++ .../KeyboardLayout/KeyboardLayoutView.swift | 67 ++ Leader Key/Settings.swift | 1 + Leader Key/Settings/AdvancedPane.swift | 21 + Leader Key/Settings/KeyboardPane.swift | 845 ++++++++++++++++++ Leader Key/Themes/Cheater.swift | 2 +- Leader Key/URLSchemeHandler.swift | 4 +- Leader Key/UserConfig.swift | 37 + Leader Key/UserState.swift | 10 + 18 files changed, 1756 insertions(+), 181 deletions(-) create mode 100644 Leader Key/Cheatsheet/CheatsheetRows.swift create mode 100644 Leader Key/Cheatsheet/KeyBadge.swift create mode 100644 Leader Key/KeyboardLayout/KeyView.swift create mode 100644 Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift create mode 100644 Leader Key/KeyboardLayout/KeyboardLayoutModel.swift create mode 100644 Leader Key/KeyboardLayout/KeyboardLayoutView.swift create mode 100644 Leader Key/Settings/KeyboardPane.swift diff --git a/Leader Key.xcodeproj/project.pbxproj b/Leader Key.xcodeproj/project.pbxproj index 141cebf6..7128397d 100644 --- a/Leader Key.xcodeproj/project.pbxproj +++ b/Leader Key.xcodeproj/project.pbxproj @@ -41,6 +41,13 @@ 427C18502BD6652500955B98 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C184F2BD6652500955B98 /* Util.swift */; }; 427C18542BD6E59300955B98 /* NSWindow+Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18532BD6E59300955B98 /* NSWindow+Animations.swift */; }; 4284834C2E813212009D7EEF /* KeyboardLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */; }; + 4284834E2E813214009D7EEF /* KeyboardCheatsheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4284834D2E813213009D7EEF /* KeyboardCheatsheetView.swift */; }; + 428483502E813216009D7EEF /* KeyboardLayoutModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4284834F2E813215009D7EEF /* KeyboardLayoutModel.swift */; }; + 428483522E813218009D7EEF /* KeyboardLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428483512E813217009D7EEF /* KeyboardLayoutView.swift */; }; + 428483542E81321A009D7EEF /* KeyboardPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428483532E813219009D7EEF /* KeyboardPane.swift */; }; + 428483562E81321C009D7EEF /* KeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428483572E81321D009D7EEF /* KeyView.swift */; }; + 428483582E81321E009D7EEF /* KeyBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428483592E81321F009D7EEF /* KeyBadge.swift */; }; + 4284835A2E813220009D7EEF /* CheatsheetRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4284835B2E813221009D7EEF /* CheatsheetRows.swift */; }; 42B21FBC2D67566100F4A2C7 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B21FBB2D67566100F4A2C7 /* Alerts.swift */; }; 42CCB5A32DAD257700356FC0 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = FBCA04D82D9F02F700271163 /* Kingfisher */; }; 42DFCD722D5B7D48002EA111 /* Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DFCD712D5B7D46002EA111 /* Events.swift */; }; @@ -102,6 +109,13 @@ 427C184F2BD6652500955B98 /* Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Util.swift; sourceTree = ""; }; 427C18532BD6E59300955B98 /* NSWindow+Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Animations.swift"; sourceTree = ""; }; 4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayoutTests.swift; sourceTree = ""; }; + 4284834D2E813213009D7EEF /* KeyboardCheatsheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardCheatsheetView.swift; sourceTree = ""; }; + 4284834F2E813215009D7EEF /* KeyboardLayoutModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayoutModel.swift; sourceTree = ""; }; + 428483512E813217009D7EEF /* KeyboardLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayoutView.swift; sourceTree = ""; }; + 428483532E813219009D7EEF /* KeyboardPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPane.swift; sourceTree = ""; }; + 428483572E81321D009D7EEF /* KeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyView.swift; sourceTree = ""; }; + 428483592E81321F009D7EEF /* KeyBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyBadge.swift; sourceTree = ""; }; + 4284835B2E813221009D7EEF /* CheatsheetRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatsheetRows.swift; sourceTree = ""; }; 42B21FBB2D67566100F4A2C7 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; 42DFCD712D5B7D46002EA111 /* Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Events.swift; sourceTree = ""; }; 42F4CDC82D458FF700D0DD76 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = ""; }; @@ -158,10 +172,31 @@ children = ( 427C18242BD31E2E00955B98 /* GeneralPane.swift */, 42FDC3192D51687B004F5C5C /* AdvancedPane.swift */, + 428483532E813219009D7EEF /* KeyboardPane.swift */, ); path = Settings; sourceTree = ""; }; + 428483552E81321B009D7EEF /* KeyboardLayout */ = { + isa = PBXGroup; + children = ( + 4284834D2E813213009D7EEF /* KeyboardCheatsheetView.swift */, + 4284834F2E813215009D7EEF /* KeyboardLayoutModel.swift */, + 428483512E813217009D7EEF /* KeyboardLayoutView.swift */, + 428483572E81321D009D7EEF /* KeyView.swift */, + ); + path = KeyboardLayout; + sourceTree = ""; + }; + 4284835C2E813222009D7EEF /* Cheatsheet */ = { + isa = PBXGroup; + children = ( + 428483592E81321F009D7EEF /* KeyBadge.swift */, + 4284835B2E813221009D7EEF /* CheatsheetRows.swift */, + ); + path = Cheatsheet; + sourceTree = ""; + }; 427C17DE2BD311B400955B98 = { isa = PBXGroup; children = ( @@ -205,6 +240,8 @@ 427C182E2BD3206200955B98 /* UserState.swift */, 427C184F2BD6652500955B98 /* Util.swift */, 427C17EE2BD311B500955B98 /* Assets.xcassets */, + 4284835C2E813222009D7EEF /* Cheatsheet */, + 428483552E81321B009D7EEF /* KeyboardLayout */, 423632242D68CC5D00878D92 /* Settings */, 427C18362BD3243C00955B98 /* Support */, 423632232D68CB0F00878D92 /* Themes */, @@ -443,6 +480,13 @@ 42F4CDCD2D45B13600D0DD76 /* KeyButton.swift in Sources */, 606C56EF2DAB875A00198B9F /* Cheater.swift in Sources */, 427C181C2BD314B500955B98 /* Constants.swift in Sources */, + 4284834E2E813214009D7EEF /* KeyboardCheatsheetView.swift in Sources */, + 428483502E813216009D7EEF /* KeyboardLayoutModel.swift in Sources */, + 428483522E813218009D7EEF /* KeyboardLayoutView.swift in Sources */, + 428483542E81321A009D7EEF /* KeyboardPane.swift in Sources */, + 428483562E81321C009D7EEF /* KeyView.swift in Sources */, + 428483582E81321E009D7EEF /* KeyBadge.swift in Sources */, + 4284835A2E813220009D7EEF /* CheatsheetRows.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Leader Key/AppDelegate.swift b/Leader Key/AppDelegate.swift index 36411839..4b2a40c4 100644 --- a/Leader Key/AppDelegate.swift +++ b/Leader Key/AppDelegate.swift @@ -35,6 +35,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, contentView: { AdvancedPane().environmentObject(self.config) }), + Settings.Pane( + identifier: .keyboard, title: "Keyboard", + toolbarIcon: NSImage( + systemSymbolName: "keyboard", accessibilityDescription: "Keyboard")!, + contentView: { + KeyboardPane().environmentObject(self.config) + }), ], style: .segmentedControl, ) diff --git a/Leader Key/Cheatsheet.swift b/Leader Key/Cheatsheet.swift index 1dc54bda..c04e0b6b 100644 --- a/Leader Key/Cheatsheet.swift +++ b/Leader Key/Cheatsheet.swift @@ -3,195 +3,111 @@ import Kingfisher import SwiftUI enum Cheatsheet { - private static let iconSize = NSSize(width: 24, height: 24) + static func createWindow(for userState: UserState) -> NSWindow { + let view = CheatsheetView().environmentObject(userState) + let controller = NSHostingController(rootView: view) + controller.sizingOptions = .preferredContentSize + let cheatsheet = PanelWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 640) + ) + cheatsheet.contentViewController = controller + return cheatsheet + } +} - struct KeyBadge: SwiftUI.View { - let key: String +struct CheatsheetView: SwiftUI.View { + @EnvironmentObject var userState: UserState + @Default(.cheatsheetStyle) var cheatsheetStyle + @State private var contentHeight: CGFloat = 0 - var body: some SwiftUI.View { - Text(KeyMaps.glyph(for: key) ?? key) - .font(.system(.body, design: .rounded)) - .multilineTextAlignment(.center) - .fontWeight(.bold) - .padding(.vertical, 4) - .frame(width: 24) - .background(.white.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 5.0, style: .continuous)) + var maxHeight: CGFloat { + if let screen = NSScreen.main { + return screen.visibleFrame.height - 40 } + return 640 } - struct ActionRow: SwiftUI.View { - let action: Action - let indent: Int - @Default(.showDetailsInCheatsheet) var showDetails - @Default(.showAppIconsInCheatsheet) var showIcons - - var body: some SwiftUI.View { - HStack { - HStack { - ForEach(0.. screenHalf ? screenHalf - margin : desiredWidth } + return 700 } - struct GroupRow: SwiftUI.View { - @Default(.expandGroupsInCheatsheet) var expand - @Default(.showDetailsInCheatsheet) var showDetails - @Default(.showAppIconsInCheatsheet) var showIcons + var actions: [ActionOrGroup] { + (userState.currentGroup != nil) + ? userState.currentGroup!.actions : userState.userConfig.root.actions + } - let group: Group - let indent: Int + var body: some SwiftUI.View { + switch cheatsheetStyle { + case .list: + listView + case .keyboard: + KeyboardCheatsheetView() + .fixedSize() + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } - var body: some SwiftUI.View { + var listView: some View { + ScrollView { VStack(alignment: .leading, spacing: 4) { - HStack { - ForEach(0.. screenHalf ? screenHalf - margin : desiredWidth - } - return 580 - } - - var actions: [ActionOrGroup] { - (userState.currentGroup != nil) - ? userState.currentGroup!.actions : userState.userConfig.root.actions - } - - var body: some SwiftUI.View { - ScrollView { - SwiftUI.VStack(alignment: .leading, spacing: 4) { - if let group = userState.currentGroup { - HStack { - KeyBadge(key: group.key ?? "•") - Text(group.key == nil ? "Leader Key" : group.displayName) - .foregroundStyle(.secondary) - } + .padding(.bottom, 8) + Divider() .padding(.bottom, 8) - Divider() - .padding(.bottom, 8) - } + } - ForEach(Array(actions.enumerated()), id: \.offset) { _, item in - switch item { - case .action(let action): - Cheatsheet.ActionRow(action: action, indent: 0) - case .group(let group): - Cheatsheet.GroupRow(group: group, indent: 0) - } + ForEach(Array(actions.enumerated()), id: \.offset) { _, item in + switch item { + case .action(let action): + ActionRow(action: action, indent: 0) + case .group(let group): + GroupRow(group: group, indent: 0) } } - .padding() - .overlay( - GeometryReader { geo in - Color.clear.preference( - key: HeightPreferenceKey.self, - value: geo.size.height - ) - } - ) } - .frame(width: Cheatsheet.CheatsheetView.preferredWidth) - .frame(height: min(contentHeight, maxHeight)) - .background( - VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + .padding() + .overlay( + GeometryReader { geo in + Color.clear.preference( + key: HeightPreferenceKey.self, + value: geo.size.height + ) + } ) - .onPreferenceChange(HeightPreferenceKey.self) { height in - self.contentHeight = height - } } - } - - struct HeightPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() + .frame(width: CheatsheetView.preferredWidth) + .frame(height: min(contentHeight, maxHeight)) + .background( + VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + ) + .onPreferenceChange(HeightPreferenceKey.self) { height in + self.contentHeight = height } } +} - static func createWindow(for userState: UserState) -> NSWindow { - let view = CheatsheetView().environmentObject(userState) - let controller = NSHostingController(rootView: view) - let cheatsheet = PanelWindow( - contentRect: NSRect(x: 0, y: 0, width: 580, height: 640) - ) - cheatsheet.contentViewController = controller - return cheatsheet +struct HeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() } } struct CheatsheetView_Previews: PreviewProvider { static var previews: some View { - Cheatsheet.CheatsheetView() + CheatsheetView() .environmentObject(UserState(userConfig: UserConfig())) } -} +} \ No newline at end of file diff --git a/Leader Key/Cheatsheet/CheatsheetRows.swift b/Leader Key/Cheatsheet/CheatsheetRows.swift new file mode 100644 index 00000000..59e9237d --- /dev/null +++ b/Leader Key/Cheatsheet/CheatsheetRows.swift @@ -0,0 +1,84 @@ +import SwiftUI +import Defaults + +private let iconSize = NSSize(width: 24, height: 24) + +struct ActionRow: View { + let action: Action + let indent: Int + @Default(.showDetailsInCheatsheet) var showDetails + @Default(.showAppIconsInCheatsheet) var showIcons + + var body: some View { + HStack { + HStack { + ForEach(0..() @@ -56,6 +58,7 @@ class Controller { func show() { Events.send(.willActivate) + startModifierMonitoring() let screen = Defaults[.screen].getNSScreen() ?? NSScreen() window.show(on: screen) { @@ -77,6 +80,12 @@ class Controller { func hide(afterClose: (() -> Void)? = nil) { Events.send(.willDeactivate) + stopModifierMonitoring() + + // Reset centered mode state + window.alphaValue = 1 + isCheatsheetCentered = false + userState.cheatsheetCentered = false window.hide { self.clear() @@ -89,8 +98,8 @@ class Controller { } func keyDown(with event: NSEvent) { - // Reset the delay timer - if Defaults[.autoOpenCheatsheet] == .delay { + // Reset the delay timer only if not already in centered mode + if Defaults[.autoOpenCheatsheet] == .delay && !isCheatsheetCentered { scheduleCheatsheet() } @@ -116,20 +125,30 @@ class Controller { switch event.keyCode { case KeyHelpers.backspace.rawValue: clear() - delay(1) { - self.positionCheatsheetWindow() + if !isCheatsheetCentered { + delay(1) { + self.positionCheatsheetWindow() + } } case KeyHelpers.escape.rawValue: - window.resignKey() + // If in a subgroup, go back one level; otherwise close + if !userState.navigateBack() { + window.resignKey() + } default: guard let char = charForEvent(event) else { return } handleKey(char, withModifiers: event.modifierFlags) } } - func handleKey(_ key: String, withModifiers modifiers: NSEvent.ModifierFlags? = nil, execute: Bool = true) { + func handleKey( + _ key: String, + withModifiers modifiers: NSEvent.ModifierFlags? = nil, + execute: Bool = true + ) { if key == "?" { - showCheatsheet() + let centered = Defaults[.cheatsheetStyle] == .keyboard + showCheatsheet(centered: centered) return } @@ -168,7 +187,6 @@ class Controller { } } } - // If execute is false, just stay visible showing the matched action case .group(let group): if execute, let mods = modifiers, shouldRunGroupSequenceWithModifiers(mods) { hide { @@ -182,9 +200,11 @@ class Controller { window.notFound() } - // Why do we need to wait here? - delay(1) { - self.positionCheatsheetWindow() + // Reposition cheatsheet only if not in centered mode + if !isCheatsheetCentered { + delay(1) { + self.positionCheatsheetWindow() + } } } @@ -192,7 +212,9 @@ class Controller { return shouldRunGroupSequenceWithModifiers(event.modifierFlags) } - private func shouldRunGroupSequenceWithModifiers(_ modifierFlags: NSEvent.ModifierFlags) -> Bool { + private func shouldRunGroupSequenceWithModifiers( + _ modifierFlags: NSEvent.ModifierFlags + ) -> Bool { let config = Defaults[.modifierKeyConfiguration] switch config { @@ -226,7 +248,8 @@ class Controller { // 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 + 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 @@ -270,12 +293,80 @@ class Controller { mainWindow.cheatsheetOrigin(cheatsheetSize: cheatsheet.frame.size)) } - private func showCheatsheet() { + private func centerCheatsheetWindow() { + guard let cheatsheet = cheatsheetWindow, + let screen = cheatsheetDisplayScreen() + else { + return + } + + let screenFrame = screen.visibleFrame + let cheatsheetFrame = cheatsheet.frame + + let x = screenFrame.origin.x + (screenFrame.width - cheatsheetFrame.width) / 2 + let y = screenFrame.origin.y + (screenFrame.height - cheatsheetFrame.height) / 2 + + cheatsheet.setFrameOrigin(NSPoint(x: x, y: y)) + } + + private func cheatsheetDisplayScreen() -> NSScreen? { + return window?.screen + ?? Defaults[.screen].getNSScreen() + ?? NSScreen.main + } + + private func sizeCheatsheetWindowToFitContent() { + guard let cheatsheet = cheatsheetWindow, + let contentView = cheatsheet.contentView + else { + return + } + + contentView.layoutSubtreeIfNeeded() + + let fittingSize = contentView.fittingSize + guard fittingSize.width > 0, fittingSize.height > 0 else { + return + } + + cheatsheet.setContentSize(fittingSize) + } + + private func showCheatsheet(centered: Bool = false) { if !window.hasCheatsheet { return } - positionCheatsheetWindow() + + if centered { + // Hide the main window visually but keep it as key window for input + window.alphaValue = 0 + isCheatsheetCentered = true + userState.cheatsheetCentered = true + sizeCheatsheetWindowToFitContent() + centerCheatsheetWindow() + } else { + cheatsheetWindow?.setContentSize( + NSSize(width: CheatsheetView.preferredWidth, height: 640) + ) + positionCheatsheetWindow() + } cheatsheetWindow?.orderFront(nil) + + if centered { + DispatchQueue.main.async { [weak self] in + self?.sizeCheatsheetWindowToFitContent() + self?.centerCheatsheetWindow() + } + } + } + + private func exitCenteredMode() { + if isCheatsheetCentered { + window.alphaValue = 1 + isCheatsheetCentered = false + userState.cheatsheetCentered = false + cheatsheetWindow?.orderOut(nil) + } } private func scheduleCheatsheet() { @@ -284,7 +375,9 @@ class Controller { cheatsheetTimer = Timer.scheduledTimer( withTimeInterval: Double(Defaults[.cheatsheetDelayMS]) / 1000.0, repeats: false ) { [weak self] _ in - self?.showCheatsheet() + // Show centered cheatsheet when using keyboard style, regular position for list + let centered = Defaults[.cheatsheetStyle] == .keyboard + self?.showCheatsheet(centered: centered) } } @@ -360,6 +453,23 @@ class Controller { alert.addButton(withTitle: "OK") alert.runModal() } + + private func startModifierMonitoring() { + modifierMonitor = NSEvent.addLocalMonitorForEvents( + matching: .flagsChanged + ) { [weak self] event in + self?.userState.shiftHeld = event.modifierFlags.contains(.shift) + return event + } + } + + private func stopModifierMonitoring() { + if let monitor = modifierMonitor { + NSEvent.removeMonitor(monitor) + modifierMonitor = nil + } + userState.shiftHeld = false + } } class DontActivateConfiguration { diff --git a/Leader Key/Defaults.swift b/Leader Key/Defaults.swift index 66703047..a079337a 100644 --- a/Leader Key/Defaults.swift +++ b/Leader Key/Defaults.swift @@ -31,6 +31,8 @@ extension Defaults.Keys { "showDetailsInCheatsheet", default: true, suite: defaultsSuite) static let showFaviconsInCheatsheet = Key( "showFaviconsInCheatsheet", default: true, suite: defaultsSuite) + static let cheatsheetStyle = Key( + "cheatsheetStyle", default: .list, suite: defaultsSuite) static let reactivateBehavior = Key( "reactivateBehavior", default: .hide, suite: defaultsSuite) static let screen = Key( @@ -74,3 +76,8 @@ enum Screen: String, Defaults.Serializable { case mouse case activeWindow } + +enum CheatsheetStyle: String, Defaults.Serializable { + case list + case keyboard +} diff --git a/Leader Key/KeyboardLayout/KeyView.swift b/Leader Key/KeyboardLayout/KeyView.swift new file mode 100644 index 00000000..dc737238 --- /dev/null +++ b/Leader Key/KeyboardLayout/KeyView.swift @@ -0,0 +1,163 @@ +import AppKit +import SwiftUI + +struct KeyView: View { + let keyDef: KeyDefinition + let binding: ActionOrGroup? + let isEditable: Bool + let shiftHeld: Bool + var scale: CGFloat = 1.0 + var onSelect: (() -> Void)? = nil + + @State private var isHovered = false + @State private var isCursorPushed = false + + private var iconSize: NSSize { + NSSize(width: 20 * scale, height: 20 * scale) + } + + var body: some View { + let width = + keyDef.width * KeyboardLayout.scaledKeySize(scale) + (keyDef.width - 1) + * KeyboardLayout.scaledKeySpacing(scale) + let height = KeyboardLayout.scaledKeySize(scale) + let cornerRadius = 6 * scale + + ZStack { + // Key background + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(keyBackground) + .shadow(color: .black.opacity(0.1), radius: 2 * scale, x: 0, y: 1 * scale) + + // Content + ZStack { + // Key label at top-left + VStack { + HStack { + Text(displayLabel) + .font(.system(size: 10 * scale, weight: .medium)) + .foregroundColor(keyDef.isModifier ? .secondary : .primary) + .opacity(keyDef.isModifier ? 0.3 : 0.6) + .padding(.leading, 4 * scale) + .padding(.top, 4 * scale) + Spacer() + } + Spacer() + } + + // Binding icon (centered) + if let binding = binding { + VStack { + actionIcon(item: binding, iconSize: iconSize, loadFavicons: false) + } + .overlay { + if isHovered, let label = bindingLabel { + Text(label) + .font(.system(size: 9 * scale)) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.primary) + .padding(.horizontal, 2 * scale) + .background(Color(nsColor: .windowBackgroundColor).opacity(0.9)) + .cornerRadius(4 * scale) + .fixedSize() + .offset(y: (iconSize.height / 2) + (5 * scale)) + } + } + } + + // Group indicator at bottom-right + if case .group = binding { + VStack { + Spacer() + HStack { + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 8 * scale)) + .foregroundColor(.secondary) + .padding(.trailing, 4 * scale) + .padding(.bottom, 4 * scale) + } + } + } + } + + // Hover overlay + if isHovered && !keyDef.isModifier && onSelect != nil { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.accentColor.opacity(0.2)) + } + } + .frame(width: width, height: height) + .contentShape(Rectangle()) + .onHover { hovering in + isHovered = hovering + + let shouldUsePointingHand = hovering && (!keyDef.isModifier || isShiftKey) && onSelect != nil + if shouldUsePointingHand { + if !isCursorPushed { + NSCursor.pointingHand.push() + isCursorPushed = true + } + } else if isCursorPushed { + NSCursor.pop() + isCursorPushed = false + } + } + .onDisappear { + if isCursorPushed { + NSCursor.pop() + isCursorPushed = false + } + } + .onTapGesture { + if !keyDef.isModifier || isShiftKey { + onSelect?() + } + } + .zIndex(isHovered ? 1 : 0) + } + + private var displayLabel: String { + if shiftHeld && !keyDef.isModifier { + let shiftedKey = KeyboardLayout.shiftedKey(for: keyDef.key) + return KeyMaps.glyph(for: shiftedKey) ?? shiftedKey.uppercased() + } + + let displayKey: String + if keyDef.key.hasSuffix("_r") { + displayKey = String(keyDef.key.dropLast(2)) + } else { + displayKey = keyDef.key + } + + return KeyMaps.glyph(for: displayKey) ?? keyDef.label + } + + private var isShiftKey: Bool { + keyDef.key == "shift" || keyDef.key == "shift_r" + } + + private var keyBackground: Color { + if isShiftKey { + return shiftHeld ? Color.accentColor.opacity(0.4) : Color.gray.opacity(0.1) + } else if keyDef.isModifier { + return Color.gray.opacity(0.05) + } else if binding != nil { + return Color.accentColor.opacity(0.15) + } else { + return Color.gray.opacity(0.1) + } + } + + private var bindingLabel: String? { + switch binding { + case .action(let action): + return action.displayName + case .group(let group): + return group.displayName + case .none: + return nil + } + } +} diff --git a/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift b/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift new file mode 100644 index 00000000..53ade487 --- /dev/null +++ b/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct KeyboardCheatsheetView: View { + @EnvironmentObject var userState: UserState + + var scale: CGFloat { + userState.cheatsheetCentered ? KeyboardLayout.centeredScale : 1.0 + } + + var bindings: [String: ActionOrGroup] { + var result: [String: ActionOrGroup] = [:] + + let actions = + (userState.currentGroup != nil) + ? userState.currentGroup!.actions + : userState.userConfig.root.actions + + for item in actions { + switch item { + case .action(let action): + if let key = action.key { + result[key] = item + } + case .group(let group): + if let key = group.key { + result[key] = item + } + } + } + + return result + } + + var body: some View { + VStack(alignment: .leading, spacing: 8 * scale) { + // Fixed height header area - always present to maintain layout + HStack { + if let group = userState.currentGroup { + Text(group.displayName) + .font(.system(size: 12 * scale, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 8 * scale) + .padding(.vertical, 4 * scale) + .background(Color.accentColor.opacity(0.8)) + .clipShape(RoundedRectangle(cornerRadius: 6 * scale, style: .continuous)) + } + Spacer() + } + .frame(height: 28 * scale) + + // Keyboard layout + KeyboardLayoutView( + bindings: bindings, + isEditable: false, + shiftHeld: userState.shiftHeld, + scale: scale + ) + } + .padding(16 * scale) + .background( + VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + ) + } +} + +#Preview { + let config = UserConfig() + let state = UserState(userConfig: config) + + return KeyboardCheatsheetView() + .environmentObject(state) + .frame(width: 700, height: 350) +} diff --git a/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift b/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift new file mode 100644 index 00000000..0ce96dfc --- /dev/null +++ b/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift @@ -0,0 +1,174 @@ +import Foundation + +/// Defines a single key on the keyboard +struct KeyDefinition: Identifiable { + let id = UUID() + let label: String // Display label ("Q", "Tab", "Space") + let key: String // Config key for binding lookup ("q", "tab", "space") + let width: CGFloat // Key width multiplier (1.0 = standard key) + let isModifier: Bool // Whether this is a non-bindable modifier key + + init(label: String, key: String, width: CGFloat = 1.0, isModifier: Bool = false) { + self.label = label + self.key = key + self.width = width + self.isModifier = isModifier + } +} + +/// QWERTY keyboard layout definition +enum KeyboardLayout { + static let keySize: CGFloat = 40 + static let keySpacing: CGFloat = 4 + static let rowSpacing: CGFloat = 4 + + /// Scale factor for centered cheatsheet mode (larger display) + static let centeredScale: CGFloat = 1.4 + + /// Get scaled key size + static func scaledKeySize(_ scale: CGFloat) -> CGFloat { + keySize * scale + } + + /// Get scaled key spacing + static func scaledKeySpacing(_ scale: CGFloat) -> CGFloat { + keySpacing * scale + } + + /// Get scaled row spacing + static func scaledRowSpacing(_ scale: CGFloat) -> CGFloat { + rowSpacing * scale + } + + /// Maps keys to their shifted versions for display and binding lookup + static let shiftedKeys: [String: String] = [ + "`": "~", + "1": "!", + "2": "@", + "3": "#", + "4": "$", + "5": "%", + "6": "^", + "7": "&", + "8": "*", + "9": "(", + "0": ")", + "-": "_", + "=": "+", + "[": "{", + "]": "}", + "\\": "|", + ";": ":", + "'": "\"", + ",": "<", + ".": ">", + "/": "?", + ] + + /// Get the shifted version of a key (for both display and binding lookup) + static func shiftedKey(for key: String) -> String { + // Letters just uppercase + if key.count == 1, let char = key.first, char.isLetter { + return key.uppercased() + } + // Special characters use the mapping + return shiftedKeys[key] ?? key + } + + /// Calculate total keyboard width based on widest row + static var totalWidth: CGFloat { + let widestRow = rows.max(by: { rowWidth($0) < rowWidth($1) }) ?? [] + return rowWidth(widestRow) + } + + /// Calculate width of a row + static func rowWidth(_ row: [KeyDefinition]) -> CGFloat { + let totalUnits = row.reduce(0) { $0 + $1.width } + return totalUnits * keySize + (totalUnits - 1) * keySpacing + } + + /// All keyboard rows + static let rows: [[KeyDefinition]] = [ + row0, row1, row2, row3, row4, + ] + + // Row 0: ` 1 2 3 4 5 6 7 8 9 0 - = Backspace (total: 15u) + static let row0: [KeyDefinition] = [ + KeyDefinition(label: "`", key: "`"), + KeyDefinition(label: "1", key: "1"), + KeyDefinition(label: "2", key: "2"), + KeyDefinition(label: "3", key: "3"), + KeyDefinition(label: "4", key: "4"), + KeyDefinition(label: "5", key: "5"), + KeyDefinition(label: "6", key: "6"), + KeyDefinition(label: "7", key: "7"), + KeyDefinition(label: "8", key: "8"), + KeyDefinition(label: "9", key: "9"), + KeyDefinition(label: "0", key: "0"), + KeyDefinition(label: "-", key: "-"), + KeyDefinition(label: "=", key: "="), + KeyDefinition(label: "⌫", key: "backspace", width: 2.0, isModifier: true), + ] + + // Row 1: Tab Q W E R T Y U I O P [ ] \ (total: 15u) + static let row1: [KeyDefinition] = [ + KeyDefinition(label: "⇥", key: "tab", width: 1.5), + KeyDefinition(label: "Q", key: "q"), + KeyDefinition(label: "W", key: "w"), + KeyDefinition(label: "E", key: "e"), + KeyDefinition(label: "R", key: "r"), + KeyDefinition(label: "T", key: "t"), + KeyDefinition(label: "Y", key: "y"), + KeyDefinition(label: "U", key: "u"), + KeyDefinition(label: "I", key: "i"), + KeyDefinition(label: "O", key: "o"), + KeyDefinition(label: "P", key: "p"), + KeyDefinition(label: "[", key: "["), + KeyDefinition(label: "]", key: "]"), + KeyDefinition(label: "\\", key: "\\", width: 1.5), + ] + + // Row 2: Caps A S D F G H J K L ; ' Enter (total: 15u) + static let row2: [KeyDefinition] = [ + KeyDefinition(label: "⇪", key: "caps", width: 1.75, isModifier: true), + KeyDefinition(label: "A", key: "a"), + KeyDefinition(label: "S", key: "s"), + KeyDefinition(label: "D", key: "d"), + KeyDefinition(label: "F", key: "f"), + KeyDefinition(label: "G", key: "g"), + KeyDefinition(label: "H", key: "h"), + KeyDefinition(label: "J", key: "j"), + KeyDefinition(label: "K", key: "k"), + KeyDefinition(label: "L", key: "l"), + KeyDefinition(label: ";", key: ";"), + KeyDefinition(label: "'", key: "'"), + KeyDefinition(label: "⏎", key: "enter", width: 2.25), + ] + + // Row 3: Shift Z X C V B N M , . / Shift (total: 15u) + static let row3: [KeyDefinition] = [ + KeyDefinition(label: "⇧", key: "shift", width: 2.25, isModifier: true), + KeyDefinition(label: "Z", key: "z"), + KeyDefinition(label: "X", key: "x"), + KeyDefinition(label: "C", key: "c"), + KeyDefinition(label: "V", key: "v"), + KeyDefinition(label: "B", key: "b"), + KeyDefinition(label: "N", key: "n"), + KeyDefinition(label: "M", key: "m"), + KeyDefinition(label: ",", key: ","), + KeyDefinition(label: ".", key: "."), + KeyDefinition(label: "/", key: "/"), + KeyDefinition(label: "⇧", key: "shift_r", width: 2.75, isModifier: true), + ] + + // Row 4: Ctrl Opt Cmd Space Cmd Opt (total: 15u) + static let row4: [KeyDefinition] = [ + KeyDefinition(label: "⌃", key: "ctrl", width: 1.5, isModifier: true), + KeyDefinition(label: "⌥", key: "opt", width: 1.25, isModifier: true), + KeyDefinition(label: "⌘", key: "cmd", width: 1.5, isModifier: true), + KeyDefinition(label: "", key: "space", width: 6.5), + KeyDefinition(label: "⌘", key: "cmd_r", width: 1.5, isModifier: true), + KeyDefinition(label: "⌥", key: "opt_r", width: 1.25, isModifier: true), + KeyDefinition(label: "⌃", key: "ctrl_r", width: 1.5, isModifier: true), + ] +} diff --git a/Leader Key/KeyboardLayout/KeyboardLayoutView.swift b/Leader Key/KeyboardLayout/KeyboardLayoutView.swift new file mode 100644 index 00000000..1994f353 --- /dev/null +++ b/Leader Key/KeyboardLayout/KeyboardLayoutView.swift @@ -0,0 +1,67 @@ +import AppKit +import SwiftUI + +struct KeyboardLayoutView: View { + let bindings: [String: ActionOrGroup] + let isEditable: Bool + let shiftHeld: Bool + var scale: CGFloat = 1.0 + var onKeySelected: ((String) -> Void)? = nil + + var body: some View { + VStack(alignment: .leading, spacing: KeyboardLayout.scaledRowSpacing(scale)) { + ForEach(Array(KeyboardLayout.rows.enumerated()), id: \.offset) { _, row in + HStack(spacing: KeyboardLayout.scaledKeySpacing(scale)) { + ForEach(row) { keyDef in + KeyView( + keyDef: keyDef, + binding: getBinding(for: keyDef), + isEditable: isEditable, + shiftHeld: shiftHeld, + scale: scale, + onSelect: { + onKeySelected?(keyDef.key) + } + ) + } + } + .fixedSize() + } + } + .fixedSize() + } + + private func getBinding(for keyDef: KeyDefinition) -> ActionOrGroup? { + if shiftHeld { + let shiftedKey = KeyboardLayout.shiftedKey(for: keyDef.key) + return bindings[shiftedKey] + } + return bindings[keyDef.key] + } +} + +#Preview { + let sampleBindings: [String: ActionOrGroup] = [ + "s": .action( + Action( + key: "s", + type: .application, + label: "Safari", + value: "/Applications/Safari.app" + )), + "v": .group( + Group( + key: "v", + label: "VSCode", + actions: [] + )), + ] + + KeyboardLayoutView( + bindings: sampleBindings, + isEditable: false, + shiftHeld: false + ) + .frame(width: 700, height: 300) + .padding() +} diff --git a/Leader Key/Settings.swift b/Leader Key/Settings.swift index 1eb1af27..96f5ecd3 100644 --- a/Leader Key/Settings.swift +++ b/Leader Key/Settings.swift @@ -3,4 +3,5 @@ import Settings extension Settings.PaneIdentifier { static let general = Self("general") static let advanced = Self("advanced") + static let keyboard = Self("keyboard") } diff --git a/Leader Key/Settings/AdvancedPane.swift b/Leader Key/Settings/AdvancedPane.swift index 62cdfb26..25e41602 100644 --- a/Leader Key/Settings/AdvancedPane.swift +++ b/Leader Key/Settings/AdvancedPane.swift @@ -13,6 +13,7 @@ struct AdvancedPane: View { @Default(.modifierKeyConfiguration) var modifierKeyConfiguration @Default(.autoOpenCheatsheet) var autoOpenCheatsheet @Default(.cheatsheetDelayMS) var cheatsheetDelayMS + @Default(.cheatsheetStyle) var cheatsheetStyle @Default(.reactivateBehavior) var reactivateBehavior @Default(.showAppIconsInCheatsheet) var showAppIconsInCheatsheet @Default(.screen) var screen @@ -115,6 +116,26 @@ struct AdvancedPane: View { Defaults.Toggle( "Show item details in cheatsheet", key: .showDetailsInCheatsheet) + Divider() + .padding(.vertical, 8) + + HStack(alignment: .firstTextBaseline) { + Text("Cheatsheet Style:") + Picker("", selection: $cheatsheetStyle) { + Text("List").tag(CheatsheetStyle.list) + Text("Keyboard").tag(CheatsheetStyle.keyboard) + } + .pickerStyle(.segmented) + .frame(width: 200) + .labelsHidden() + + Spacer() + } + + Text("Choose how shortcuts are displayed in the cheatsheet overlay") + .foregroundColor(.secondary) + .font(.callout) + } Settings.Section(title: "Activation", bottomDivider: true) { diff --git a/Leader Key/Settings/KeyboardPane.swift b/Leader Key/Settings/KeyboardPane.swift new file mode 100644 index 00000000..f34aca00 --- /dev/null +++ b/Leader Key/Settings/KeyboardPane.swift @@ -0,0 +1,845 @@ +import AppKit +import Defaults +import Settings +import SwiftUI +import UniformTypeIdentifiers + +struct EditorWrapper: Identifiable { + var id: String { item.uiid.uuidString } + var item: ActionOrGroup + var keyName: String + var isNew: Bool = false +} + +struct UnifiedEditorView: View { + @Binding var item: ActionOrGroup + var keyName: String + var isNew: Bool + var onSave: (ActionOrGroup) -> Void + var onDelete: (ActionOrGroup) -> Void + var onOpenLayout: (ActionOrGroup) -> Void + + @State private var showIconPicker = false + + // Temporary state to hold values when switching types + @State private var actionType: Type = .application + @State private var label: String = "" + @State private var value: String = "" + @State private var iconPath: String? + + @FocusState private var isValueFieldFocused: Bool + + // Type selection for the segmented control + enum ItemType: String, CaseIterable { + case action = "Action" + case group = "Group" + } + + @State private var selectedType: ItemType = .action + + init( + item: Binding, + keyName: String, + isNew: Bool, + onSave: @escaping (ActionOrGroup) -> Void, + onDelete: @escaping (ActionOrGroup) -> Void, + onOpenLayout: @escaping (ActionOrGroup) -> Void + ) { + self._item = item + self.keyName = keyName + self.isNew = isNew + self.onSave = onSave + self.onDelete = onDelete + self.onOpenLayout = onOpenLayout + + // Initialize local state based on input item + switch item.wrappedValue { + case .action(let action): + _selectedType = State(initialValue: .action) + _actionType = State(initialValue: action.type) + _label = State(initialValue: action.label ?? "") + _value = State(initialValue: action.value) + _iconPath = State(initialValue: action.iconPath) + case .group(let group): + _selectedType = State(initialValue: .group) + _label = State(initialValue: group.label ?? "") + _iconPath = State(initialValue: group.iconPath) + } + } + + var body: some View { + VStack(spacing: 20) { + // Key being edited indicator + Text("Editing '\(keyName)'") + .font(.title2) + .foregroundColor(.secondary) + .padding(.top, 12) + + // Header: Icon & Label + VStack(spacing: 12) { + // Icon Picker + Button { + showIconPicker = true + } label: { + if let icon = resolveIcon() { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + .shadow(radius: 2) + } else { + ZStack { + Circle().fill(Color.secondary.opacity(0.1)) + .frame(width: 64, height: 64) + Image(systemName: "photo") + .font(.system(size: 24)) + .foregroundColor(.secondary) + } + } + } + .buttonStyle(.plain) + .overlay(alignment: .topTrailing) { + if iconPath != nil { + Button { + iconPath = nil + updateItem() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .background(Circle().fill(Color(nsColor: .windowBackgroundColor))) + } + .buttonStyle(.plain) + .offset(x: 4, y: -4) + } + } + + // Label Input + TextField("Label", text: $label) + .textFieldStyle(.plain) + .font(.title2) + .multilineTextAlignment(.center) + .padding(6) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + .frame(maxWidth: 220) + .onChange(of: label) { _ in updateItem() } + } + .padding(.top, 24) + + // Type Switcher + Picker("", selection: $selectedType) { + ForEach(ItemType.allCases, id: \.self) { type in + Text(type.rawValue).tag(type) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 32) + .onChange(of: selectedType) { newType in + updateItemType(newType) + } + + // Content Area + VStack(alignment: .leading, spacing: 16) { + if selectedType == .action { + // Action Type Picker + HStack { + Text("Action Type") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Picker("", selection: $actionType) { + Text("Application").tag(Type.application) + Text("URL").tag(Type.url) + Text("Command").tag(Type.command) + Text("Folder").tag(Type.folder) + } + .labelsHidden() + .fixedSize() + } + .onChange(of: actionType) { _ in updateItem() } + + // Value Input + VStack(alignment: .leading, spacing: 6) { + Text(valueLabel) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + + HStack { + TextField("Enter value...", text: $value) + .textFieldStyle(.roundedBorder) + .focused($isValueFieldFocused) + .onTapGesture { + isValueFieldFocused = true + } + .onChange(of: value) { _ in updateItem() } + + if actionType == .application || actionType == .folder { + Button { + openFilePicker() + } label: { + Image(systemName: "folder") + } + .buttonStyle(.bordered) + } + } + } + } else { + // Group Mode + VStack(spacing: 16) { + if case .group = item { + Button { + let updated = getUpdatedItem() + item = updated + onSave(updated) + onOpenLayout(updated) + } label: { + HStack { + Image(systemName: "square.grid.3x3") + Text("Edit Group Layout") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Text("Assign actions to keys within this group.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + .padding(.top, 10) + } + } + .padding(.horizontal, 24) + + Spacer() + + // Footer + HStack { + if !isNew { + Button(role: .destructive) { + onDelete(item) + } label: { + Image(systemName: "trash") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Delete Item") + } + + Spacer() + + Button("Done") { + let updated = getUpdatedItem() + item = updated + onSave(updated) + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.regular) + } + .padding(20) + .background(Color(nsColor: .controlBackgroundColor)) + } + .frame(width: 320, height: 480) + .fileImporter( + isPresented: $showIconPicker, + allowedContentTypes: [.image, .icns], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let urls): + if let url = urls.first { + iconPath = url.path + updateItem() + } + case .failure(let error): + print("Icon selection error: \(error.localizedDescription)") + } + } + } + + private func updateItemType(_ type: ItemType) { + let key = item.key + if type == .action { + item = .action( + Action( + uiid: item.uiid, + key: key, + type: actionType, + label: label.isEmpty ? nil : label, + value: value, + iconPath: iconPath + ) + ) + } else { + let existingActions: [ActionOrGroup] + if case .group(let existingGroup) = item { + existingActions = existingGroup.actions + } else { + existingActions = [] + } + item = .group( + Group( + uiid: item.uiid, + key: key, + label: label.isEmpty ? nil : label, + iconPath: iconPath, + actions: existingActions + ) + ) + } + } + + private func updateItem() { + item = getUpdatedItem() + } + + private func getUpdatedItem() -> ActionOrGroup { + let key = item.key + let currentLabel = label.isEmpty ? nil : label + + switch selectedType { + case .action: + if case .action(let existingAction) = item { + var updated = existingAction + updated.key = key + updated.type = actionType + updated.label = currentLabel + updated.value = value + updated.iconPath = iconPath + return .action(updated) + } else { + return .action( + Action( + uiid: item.uiid, + key: key, + type: actionType, + label: currentLabel, + value: value, + iconPath: iconPath + ) + ) + } + case .group: + if case .group(let existingGroup) = item { + var newGroup = existingGroup + newGroup.label = currentLabel + newGroup.iconPath = iconPath + return .group(newGroup) + } else { + return .group( + Group( + uiid: item.uiid, + key: key, + label: currentLabel, + iconPath: iconPath, + actions: [] + ) + ) + } + } + } + + private func resolveIcon() -> NSImage? { + switch item { + case .action(let action): return action.resolvedIcon() + case .group(let group): return group.resolvedIcon() + } + } + + private var valueLabel: String { + switch actionType { + case .application: return "Application Path" + case .url: return "URL" + case .command: return "Shell Command" + case .folder: return "Folder Path" + default: return "Value" + } + } + + private var allowedFileTypes: [UTType] { + switch actionType { + case .application: return [.application, .applicationBundle] + case .folder: return [.folder] + default: return [] + } + } + + private func openFilePicker() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = actionType == .folder + panel.canChooseFiles = actionType != .folder + panel.allowedContentTypes = allowedFileTypes + + if actionType == .application { + panel.directoryURL = URL(fileURLWithPath: "/Applications") + } + + panel.begin { response in + if response == .OK, let url = panel.url { + self.value = url.path + self.updateItem() + } + } + } +} + +struct GroupWrapper: Identifiable { + var id: String { group.uiid.uuidString } + var group: KeyGroup +} + +struct GroupEditorView: View { + @Binding var group: KeyGroup + var onSave: () -> Void + var onDelete: () -> Void + var onOpenLayout: () -> Void + + @State private var showIconPicker = false + + var body: some View { + VStack(spacing: 20) { + // Header: Icon & Label + VStack(spacing: 12) { + // Icon Picker + Button { + showIconPicker = true + } label: { + if let icon = group.resolvedIcon() { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 64, height: 64) + .shadow(radius: 2) + } else { + ZStack { + Circle().fill(Color.secondary.opacity(0.1)) + .frame(width: 64, height: 64) + Image(systemName: "folder") + .font(.system(size: 24)) + .foregroundColor(.secondary) + } + } + } + .buttonStyle(.plain) + .overlay(alignment: .topTrailing) { + if group.iconPath != nil { + Button { + group.iconPath = nil + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .background(Circle().fill(Color(nsColor: .windowBackgroundColor))) + } + .buttonStyle(.plain) + .offset(x: 4, y: -4) + } + } + + // Label Input + TextField( + "Label", + text: Binding( + get: { group.label ?? "" }, + set: { group.label = $0.isEmpty ? nil : $0 } + ) + ) + .textFieldStyle(.plain) + .font(.title2) + .multilineTextAlignment(.center) + .padding(6) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.3), lineWidth: 1) + ) + .frame(maxWidth: 220) + } + .padding(.top, 24) + + Text("Edit group settings") + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + // Footer + HStack { + Button(role: .destructive) { + onDelete() + } label: { + Image(systemName: "trash") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Delete Group") + + Spacer() + + Button("Done") { + onSave() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.regular) + } + .padding(20) + .background(Color(nsColor: .controlBackgroundColor)) + } + .frame(width: 320, height: 350) + .fileImporter( + isPresented: $showIconPicker, + allowedContentTypes: [.image, .icns], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let urls): + if let url = urls.first { + group.iconPath = url.path + } + case .failure(let error): + print("Icon selection error: \(error.localizedDescription)") + } + } + } +} + +struct KeyboardPane: View { + private let contentWidth = 950.0 + private let maxKeyboardScale: CGFloat = 1.4 + @EnvironmentObject private var config: UserConfig + @Default(.cheatsheetStyle) var cheatsheetStyle + + @State private var editingItem: EditorWrapper? + @State private var editingGroupWrapper: GroupWrapper? + @State private var currentGroupPath: [UUID] = [] + @State private var keyboardScale: CGFloat = 1.4 + @State private var shiftHeld = false + @State private var stickyShiftMode = false // Persists after editing capital letter keys + @State private var monitor: Any? + + var currentGroup: KeyGroup { + if let id = currentGroupPath.last { + return config.root.find(id: id) ?? config.root + } + return config.root + } + + var bindings: [String: ActionOrGroup] { + var result: [String: ActionOrGroup] = [:] + + for item in currentGroup.actions { + switch item { + case .action(let action): + if let key = action.key { + result[key] = item + } + case .group(let group): + if let key = group.key { + result[key] = item + } + } + } + + return result + } + + var body: some View { + Settings.Container(contentWidth: contentWidth) { + Settings.Section(title: "", bottomDivider: true) { + VStack(alignment: .leading, spacing: 12) { + // Breadcrumbs & Header + HStack(spacing: 4) { + if !currentGroupPath.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + // Root breadcrumb + Button { + currentGroupPath.removeAll() + } label: { + Text("Root") + } + .buttonStyle(.bordered) + + // Path breadcrumbs + ForEach(0..") + .foregroundColor(.secondary) + + let id = currentGroupPath[index] + let name = getGroupName(for: id) + let isLast = index == currentGroupPath.count - 1 + + if isLast { + // Current location - show as bold text, not a button + Text(name) + .fontWeight(.bold) + } else { + Button { + currentGroupPath = Array( + (currentGroupPath as [UUID]).prefix(index + 1) + ) + } label: { + Text(name) + } + .buttonStyle(.bordered) + } + } + } + } + + Spacer() + + // Edit Current Group Button + Button { + editingGroupWrapper = GroupWrapper(group: currentGroup) + } label: { + Image(systemName: "gearshape") + .foregroundColor(.secondary) + } + .buttonStyle(.borderless) + .help("Edit Group Settings") + } else { + // Invisible button to maintain consistent height with breadcrumb view + Button { + } label: { + Text("Root") + } + .buttonStyle(.bordered) + .opacity(0) + .disabled(true) + + Spacer() + } + } + .frame(height: 24) + + HStack(spacing: 0) { + Spacer() + KeyboardLayoutView( + bindings: bindings, + isEditable: false, + shiftHeld: shiftHeld || stickyShiftMode, + scale: maxKeyboardScale, + onKeySelected: { key in + if key == "shift" || key == "shift_r" { + stickyShiftMode.toggle() + return + } + + // Determine if we're in shift mode + let isShiftActive = shiftHeld || stickyShiftMode + + // If shift is active, persist it (sticky mode) + if isShiftActive { + stickyShiftMode = true + } + + // If shift is held, use the shifted key for lookup and creation + let lookupKey = + isShiftActive ? KeyboardLayout.shiftedKey(for: key) : key + + if let existing = bindings[lookupKey] { + switch existing { + case .action(let action): + editingItem = EditorWrapper( + item: .action(action), + keyName: lookupKey, + isNew: false + ) + case .group(let group): + // Direct navigation into group + currentGroupPath.append(group.uiid) + } + } else { + // Default to empty application action + editingItem = EditorWrapper( + item: .action( + Action(key: lookupKey, type: .application, value: "") + ), + keyName: lookupKey, + isNew: true + ) + } + } + ) + Spacer() + } + + Text("Click any key to configure") + .font(.body) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 24) + } + } + } + .sheet( + item: $editingItem, + onDismiss: { + stickyShiftMode = false + } + ) { wrapper in + UnifiedEditorView( + item: Binding( + get: { wrapper.item }, + set: { editingItem?.item = $0 } + ), + keyName: wrapper.keyName, + isNew: wrapper.isNew, + onSave: { updatedItem in + saveItem(updatedItem) + editingItem = nil + }, + onDelete: { itemToDelete in + deleteItem(itemToDelete) + editingItem = nil + }, + onOpenLayout: { updatedItem in + if case .group(let group) = updatedItem { + currentGroupPath.append(group.uiid) + editingItem = nil + } + } + ) + } + .sheet( + item: $editingGroupWrapper, + onDismiss: { + stickyShiftMode = false + } + ) { wrapper in + GroupEditorView( + group: Binding( + get: { wrapper.group }, + set: { editingGroupWrapper?.group = $0 } + ), + onSave: { + if let group = editingGroupWrapper?.group { + saveGroup(group) + } + editingGroupWrapper = nil + }, + onDelete: { + if let group = editingGroupWrapper?.group { + deleteGroup(group) + // If we deleted the current group, pop back + if !currentGroupPath.isEmpty { + currentGroupPath.removeLast() + } + } + editingGroupWrapper = nil + }, + onOpenLayout: { + editingGroupWrapper = nil + } + ) + } + .onAppear { + monitor = NSEvent.addLocalMonitorForEvents( + matching: .flagsChanged + ) { event in + let wasShiftHeld = shiftHeld + let isShiftHeld = event.modifierFlags.contains(.shift) + shiftHeld = isShiftHeld + + // If physical shift was released while in sticky mode, exit sticky mode + // BUT only if we're not currently editing (keep shift while editor is open) + if wasShiftHeld && !isShiftHeld && stickyShiftMode && editingItem == nil { + stickyShiftMode = false + } + return event + } + } + .onDisappear { + if let monitor = monitor { + NSEvent.removeMonitor(monitor) + } + monitor = nil + // Reset to root when closing settings + currentGroupPath.removeAll() + stickyShiftMode = false + } + } + + private var keyboardMaxHeight: CGFloat { + let rows = CGFloat(KeyboardLayout.rows.count) + let scaledKeySize = KeyboardLayout.keySize * maxKeyboardScale + let scaledRowSpacing = KeyboardLayout.rowSpacing * maxKeyboardScale + let keyboardHeight = (scaledKeySize * rows) + (scaledRowSpacing * (rows - 1)) + // Add padding (8px for .padding(.vertical, 4)) + 32px for the overlapping text + return ceil(keyboardHeight) + 40 + } + + private func saveItem(_ item: ActionOrGroup) { + var group = currentGroup + let newActions = group.actions.filter { existing in + // Use UUID match if updating same item, OR Key match if replacing/colliding + existing.uiid != item.uiid && existing.key != item.key + } + group.actions = newActions + [item] + + updateConfig(with: group) + } + + private func deleteItem(_ item: ActionOrGroup) { + var group = currentGroup + let newActions = group.actions.filter { existing in + existing.uiid != item.uiid + } + group.actions = newActions + + updateConfig(with: group) + } + + private func saveGroup(_ group: KeyGroup) { + updateConfig(with: group) + } + + private func deleteGroup(_ group: KeyGroup) { + if currentGroupPath.isEmpty { + return + } + + // Find parent + let parentId = currentGroupPath.dropLast().last + let parent = parentId != nil ? config.root.find(id: parentId!) : config.root + + if var p = parent { + p.actions.removeAll { $0.uiid == group.uiid } + updateConfig(with: p) + } + } + + private func updateConfig(with modifiedGroup: KeyGroup) { + if currentGroupPath.isEmpty { + config.root = modifiedGroup + } else { + var root = config.root + if root.update(group: modifiedGroup) { + config.root = root + } + } + } + + private func getGroupName(for id: UUID) -> String { + if let group = config.root.find(id: id) { + return group.displayName + } + return "Unknown" + } +} + +#Preview { + let config = UserConfig() + return KeyboardPane() + .environmentObject(config) +} diff --git a/Leader Key/Themes/Cheater.swift b/Leader Key/Themes/Cheater.swift index 30af3f40..fbbb9721 100644 --- a/Leader Key/Themes/Cheater.swift +++ b/Leader Key/Themes/Cheater.swift @@ -7,7 +7,7 @@ enum Cheater { required init(controller: Controller) { super.init(controller: controller, contentRect: NSRect(x: 0, y: 0, width: 0, height: 0)) - let view = Cheatsheet.CheatsheetView() + let view = CheatsheetView() contentView = NSHostingView(rootView: view.environmentObject(self.controller.userState)) } diff --git a/Leader Key/URLSchemeHandler.swift b/Leader Key/URLSchemeHandler.swift index fc1d8522..954698b9 100644 --- a/Leader Key/URLSchemeHandler.swift +++ b/Leader Key/URLSchemeHandler.swift @@ -36,8 +36,8 @@ class URLSchemeHandler { return .reset case "navigate": guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems, - let keysParam = queryItems.first(where: { $0.name == "keys" })?.value + let queryItems = components.queryItems, + let keysParam = queryItems.first(where: { $0.name == "keys" })?.value else { return .show } diff --git a/Leader Key/UserConfig.swift b/Leader Key/UserConfig.swift index c339d028..0ca62e76 100644 --- a/Leader Key/UserConfig.swift +++ b/Leader Key/UserConfig.swift @@ -372,6 +372,34 @@ extension UserConfig { } } +extension Group { + func find(id: UUID) -> Group? { + if self.uiid == id { return self } + for action in actions { + if case .group(let g) = action { + if let found = g.find(id: id) { return found } + } + } + return nil + } + + mutating func update(group: Group) -> Bool { + if self.uiid == group.uiid { + self = group + return true + } + for i in 0.. Bool { + if navigationPath.isEmpty { + return false + } + navigationPath.removeLast() + return true + } } From 2f8c72901a1c3b45b35d27f661cba75ea5254750 Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Mon, 15 Dec 2025 13:50:21 -0800 Subject: [PATCH 2/9] style(cheatsheet): improve background visibility on light backgrounds Change material from hudWindow to popover and add semi-transparent window background overlay (70% opacity) for better contrast while maintaining blur effect. --- Leader Key/Cheatsheet.swift | 5 ++++- Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Leader Key/Cheatsheet.swift b/Leader Key/Cheatsheet.swift index c04e0b6b..832205b5 100644 --- a/Leader Key/Cheatsheet.swift +++ b/Leader Key/Cheatsheet.swift @@ -90,7 +90,10 @@ struct CheatsheetView: SwiftUI.View { .frame(width: CheatsheetView.preferredWidth) .frame(height: min(contentHeight, maxHeight)) .background( - VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + ZStack { + VisualEffectView(material: .popover, blendingMode: .behindWindow) + Color(NSColor.windowBackgroundColor).opacity(0.7) + } ) .onPreferenceChange(HeightPreferenceKey.self) { height in self.contentHeight = height diff --git a/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift b/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift index 53ade487..699aee49 100644 --- a/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift +++ b/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift @@ -58,7 +58,10 @@ struct KeyboardCheatsheetView: View { } .padding(16 * scale) .background( - VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + ZStack { + VisualEffectView(material: .popover, blendingMode: .behindWindow) + Color(NSColor.windowBackgroundColor).opacity(0.7) + } ) } } From 799d3afb70783da57464d6e1ac1f3b52e34ccef4 Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Mon, 15 Dec 2025 17:26:31 -0800 Subject: [PATCH 3/9] fix(cheatsheet): center keyboard cheatsheet to match theme window position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the keyboard cheatsheet centering logic to align with the main theme window positioning. Both windows now share the same vertical offset (slightly above true center) for consistent visual alignment. Changes: - Modified centerCheatsheetWindow to use theme-matching vertical offset - Added multiple centering passes to handle SwiftUI layout timing - Improved showCheatsheet timing to ensure proper sizing before display 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Leader Key/Controller.swift | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/Leader Key/Controller.swift b/Leader Key/Controller.swift index 17205ac7..51b3eca5 100644 --- a/Leader Key/Controller.swift +++ b/Leader Key/Controller.swift @@ -300,11 +300,15 @@ class Controller { return } - let screenFrame = screen.visibleFrame + let screenCenter = screen.center() let cheatsheetFrame = cheatsheet.frame - let x = screenFrame.origin.x + (screenFrame.width - cheatsheetFrame.width) / 2 - let y = screenFrame.origin.y + (screenFrame.height - cheatsheetFrame.height) / 2 + // Match the vertical offset used by themes (slightly above center) + // Most themes use: center.y + windowSize / 8 + let verticalOffset: CGFloat = cheatsheetFrame.height / 8 + + let x = screenCenter.x - cheatsheetFrame.width / 2 + let y = screenCenter.y - cheatsheetFrame.height / 2 + verticalOffset cheatsheet.setFrameOrigin(NSPoint(x: x, y: y)) } @@ -344,19 +348,25 @@ class Controller { userState.cheatsheetCentered = true sizeCheatsheetWindowToFitContent() centerCheatsheetWindow() - } else { - cheatsheetWindow?.setContentSize( - NSSize(width: CheatsheetView.preferredWidth, height: 640) - ) - positionCheatsheetWindow() - } - cheatsheetWindow?.orderFront(nil) + cheatsheetWindow?.orderFront(nil) - if centered { + // Re-center after SwiftUI has finished laying out DispatchQueue.main.async { [weak self] in self?.sizeCheatsheetWindowToFitContent() self?.centerCheatsheetWindow() + + // Final centering pass to ensure accuracy + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.sizeCheatsheetWindowToFitContent() + self?.centerCheatsheetWindow() + } } + } else { + cheatsheetWindow?.setContentSize( + NSSize(width: CheatsheetView.preferredWidth, height: 640) + ) + positionCheatsheetWindow() + cheatsheetWindow?.orderFront(nil) } } From 0a57a87bc1c2af45b3f8aabce8b7db27bf94fce8 Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Mon, 15 Dec 2025 17:33:21 -0800 Subject: [PATCH 4/9] refactor(settings): streamline group editor UI with improved action flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplified the group editing interface by removing redundant elements and improving the action flow with clearer button labels. Changes: - Removed "Edit Group Layout" button from group editor body - Removed helper text "Assign actions to keys within this group" - Changed "Done" button to "Assign Actions" for groups - Added "Cancel" button for groups to dismiss without saving - "Assign Actions" button now saves and opens keyboard layout in one step 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Leader Key/Settings/KeyboardPane.swift | 43 ++++++++++---------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/Leader Key/Settings/KeyboardPane.swift b/Leader Key/Settings/KeyboardPane.swift index f34aca00..2560e521 100644 --- a/Leader Key/Settings/KeyboardPane.swift +++ b/Leader Key/Settings/KeyboardPane.swift @@ -19,6 +19,7 @@ struct UnifiedEditorView: View { var onDelete: (ActionOrGroup) -> Void var onOpenLayout: (ActionOrGroup) -> Void + @Environment(\.dismiss) private var dismiss @State private var showIconPicker = false // Temporary state to hold values when switching types @@ -188,31 +189,7 @@ struct UnifiedEditorView: View { } } else { // Group Mode - VStack(spacing: 16) { - if case .group = item { - Button { - let updated = getUpdatedItem() - item = updated - onSave(updated) - onOpenLayout(updated) - } label: { - HStack { - Image(systemName: "square.grid.3x3") - Text("Edit Group Layout") - } - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - - Text("Assign actions to keys within this group.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - } - .padding(.top, 10) + Spacer() } } .padding(.horizontal, 24) @@ -221,7 +198,16 @@ struct UnifiedEditorView: View { // Footer HStack { - if !isNew { + if selectedType == .group { + // Group mode: Cancel button + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + .buttonStyle(.bordered) + .controlSize(.regular) + } else if !isNew { + // Action mode: Delete button Button(role: .destructive) { onDelete(item) } label: { @@ -235,10 +221,13 @@ struct UnifiedEditorView: View { Spacer() - Button("Done") { + Button(selectedType == .group ? "Assign Actions" : "Done") { let updated = getUpdatedItem() item = updated onSave(updated) + if selectedType == .group { + onOpenLayout(updated) + } } .keyboardShortcut(.defaultAction) .buttonStyle(.borderedProminent) From fd708c00aaa400c7d614c066d0aeeefe53d94632 Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Mon, 15 Dec 2025 18:10:44 -0800 Subject: [PATCH 5/9] refactor(keyboard): improve breadcrumb navigation with icon-only home button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace "Root" text with house icon for cleaner UI - Use default macOS button styling for breadcrumb buttons - Adjust breadcrumb alignment to match keyboard layout edges - Add balanced left/right spacing for proper visual alignment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Leader Key/Settings/KeyboardPane.swift | 117 ++++++++++++++----------- 1 file changed, 64 insertions(+), 53 deletions(-) diff --git a/Leader Key/Settings/KeyboardPane.swift b/Leader Key/Settings/KeyboardPane.swift index 2560e521..63f0c0fc 100644 --- a/Leader Key/Settings/KeyboardPane.swift +++ b/Leader Key/Settings/KeyboardPane.swift @@ -546,68 +546,79 @@ struct KeyboardPane: View { Settings.Section(title: "", bottomDivider: true) { VStack(alignment: .leading, spacing: 12) { // Breadcrumbs & Header - HStack(spacing: 4) { - if !currentGroupPath.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 4) { - // Root breadcrumb - Button { - currentGroupPath.removeAll() - } label: { - Text("Root") - } - .buttonStyle(.bordered) - - // Path breadcrumbs - ForEach(0..") - .foregroundColor(.secondary) - - let id = currentGroupPath[index] - let name = getGroupName(for: id) - let isLast = index == currentGroupPath.count - 1 - - if isLast { - // Current location - show as bold text, not a button - Text(name) - .fontWeight(.bold) - } else { - Button { - currentGroupPath = Array( - (currentGroupPath as [UUID]).prefix(index + 1) - ) - } label: { + HStack(spacing: 0) { + // Calculate keyboard left offset to align breadcrumbs with ` key + let keyboardWidth = KeyboardLayout.totalWidth * maxKeyboardScale + let centerOffset = (contentWidth - keyboardWidth) / 2 + let leftOffset = max(0, centerOffset - 4) + let rightOffset = max(0, centerOffset - 4) + + Spacer() + .frame(width: leftOffset) + + HStack(spacing: 4) { + if !currentGroupPath.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + // Root breadcrumb + Button { + currentGroupPath.removeAll() + } label: { + Image(systemName: "house.fill") + } + + // Path breadcrumbs + ForEach(0..") + .foregroundColor(.secondary) + + let id = currentGroupPath[index] + let name = getGroupName(for: id) + let isLast = index == currentGroupPath.count - 1 + + if isLast { + // Current location - show as bold text, not a button Text(name) + .fontWeight(.bold) + } else { + Button { + currentGroupPath = Array( + (currentGroupPath as [UUID]).prefix(index + 1) + ) + } label: { + Text(name) + } } - .buttonStyle(.bordered) } } } - } - Spacer() + Spacer() - // Edit Current Group Button - Button { - editingGroupWrapper = GroupWrapper(group: currentGroup) - } label: { - Image(systemName: "gearshape") - .foregroundColor(.secondary) - } - .buttonStyle(.borderless) - .help("Edit Group Settings") - } else { - // Invisible button to maintain consistent height with breadcrumb view - Button { - } label: { - Text("Root") - } - .buttonStyle(.bordered) - .opacity(0) - .disabled(true) + // Edit Current Group Button + Button { + editingGroupWrapper = GroupWrapper(group: currentGroup) + } label: { + Image(systemName: "gearshape") + .foregroundColor(.secondary) + } + .buttonStyle(.borderless) + .help("Edit Group Settings") + } else { + // Invisible button to maintain consistent height with breadcrumb view + Button { + } label: { + Image(systemName: "house.fill") + } + .opacity(0) + .disabled(true) - Spacer() + Spacer() + } } + + Spacer() + .frame(width: rightOffset) } .frame(height: 24) From 5ac3f34df827fbf19aeaee7e50f13feeebdf8eac Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Mon, 15 Dec 2025 18:21:10 -0800 Subject: [PATCH 6/9] fix(keyboard): align spacebar and bottom row with standard layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Align spacebar left edge with C key and right edge with comma key - Adjust spacebar width from 6.5u to 6.0u for accurate alignment - Balance right Cmd (1.5u) and Opt (1.5u) keys to meet under slash key - Maintain total bottom row width at 15 units 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Leader Key/KeyboardLayout/KeyboardLayoutModel.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift b/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift index 0ce96dfc..efd54e8b 100644 --- a/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift +++ b/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift @@ -161,14 +161,16 @@ enum KeyboardLayout { KeyDefinition(label: "⇧", key: "shift_r", width: 2.75, isModifier: true), ] - // Row 4: Ctrl Opt Cmd Space Cmd Opt (total: 15u) + // Row 4: Ctrl Opt Cmd Space Cmd Opt Ctrl (total: 15u) + // Spacebar aligned with C key (left edge) and comma key (right edge) + // Right Cmd and Opt meet under center of slash key static let row4: [KeyDefinition] = [ KeyDefinition(label: "⌃", key: "ctrl", width: 1.5, isModifier: true), KeyDefinition(label: "⌥", key: "opt", width: 1.25, isModifier: true), KeyDefinition(label: "⌘", key: "cmd", width: 1.5, isModifier: true), - KeyDefinition(label: "", key: "space", width: 6.5), + KeyDefinition(label: "", key: "space", width: 6.0), KeyDefinition(label: "⌘", key: "cmd_r", width: 1.5, isModifier: true), - KeyDefinition(label: "⌥", key: "opt_r", width: 1.25, isModifier: true), - KeyDefinition(label: "⌃", key: "ctrl_r", width: 1.5, isModifier: true), + KeyDefinition(label: "⌥", key: "opt_r", width: 1.5, isModifier: true), + KeyDefinition(label: "⌃", key: "ctrl_r", width: 1.75, isModifier: true), ] } From 4a2b79e10065ff392a8f7e76f76f07f887adf361 Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Tue, 16 Dec 2025 04:31:18 -0800 Subject: [PATCH 7/9] feat(cheatsheet): add breadcrumb navigation to keyboard cheatsheet Replace single group label with full breadcrumb path showing navigation hierarchy. Breadcrumbs are read-only (non-interactive) for display purposes only. --- .../KeyboardCheatsheetView.swift | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift b/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift index 699aee49..d2a1ef45 100644 --- a/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift +++ b/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift @@ -33,16 +33,27 @@ struct KeyboardCheatsheetView: View { var body: some View { VStack(alignment: .leading, spacing: 8 * scale) { - // Fixed height header area - always present to maintain layout - HStack { - if let group = userState.currentGroup { - Text(group.displayName) - .font(.system(size: 12 * scale, weight: .semibold)) - .foregroundColor(.white) - .padding(.horizontal, 8 * scale) - .padding(.vertical, 4 * scale) - .background(Color.accentColor.opacity(0.8)) - .clipShape(RoundedRectangle(cornerRadius: 6 * scale, style: .continuous)) + // Breadcrumbs navigation + HStack(spacing: 4 * scale) { + if !userState.navigationPath.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4 * scale) { + // Path breadcrumbs + ForEach(0.. 0 { + Text(">") + .foregroundColor(.secondary) + .font(.system(size: 12 * scale)) + } + + let group = userState.navigationPath[index] + let isLast = index == userState.navigationPath.count - 1 + + Text(group.displayName) + .font(.system(size: 12 * scale, weight: isLast ? .bold : .regular)) + } + } + } } Spacer() } From ebf992e0e6193704d19300be87e0a724c584d51e Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Tue, 16 Dec 2025 04:50:59 -0800 Subject: [PATCH 8/9] feat(favicon): improve favicon display with larger size and rounded corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhanced favicon presentation in keyboard layout and cheatsheet views: - Enabled favicon loading in keyboard layout (was previously disabled) - Removed padding to display favicons at full icon size - Added rounded corners (20% radius) to match app icon styling - Maintained aspect ratio with .fit content mode for better appearance Favicons now match the visual style of native app icons throughout the UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Leader Key/KeyboardLayout/KeyView.swift | 2 +- Leader Key/Views/ActionIcon.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Leader Key/KeyboardLayout/KeyView.swift b/Leader Key/KeyboardLayout/KeyView.swift index dc737238..977fa2c8 100644 --- a/Leader Key/KeyboardLayout/KeyView.swift +++ b/Leader Key/KeyboardLayout/KeyView.swift @@ -48,7 +48,7 @@ struct KeyView: View { // Binding icon (centered) if let binding = binding { VStack { - actionIcon(item: binding, iconSize: iconSize, loadFavicons: false) + actionIcon(item: binding, iconSize: iconSize, loadFavicons: true) } .overlay { if isHovered, let label = bindingLabel { diff --git a/Leader Key/Views/ActionIcon.swift b/Leader Key/Views/ActionIcon.swift index 74d37e9a..95182988 100644 --- a/Leader Key/Views/ActionIcon.swift +++ b/Leader Key/Views/ActionIcon.swift @@ -124,8 +124,9 @@ struct FavIconImage: View { KFImage.url(URL(string: url)).placeholder({ fallback }).resizable() - .padding(4) + .aspectRatio(contentMode: .fit) .frame(width: size.width, height: size.height, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: size.width * 0.2, style: .continuous)) } else { fallback } From a409514f9a84b149a625f91b75e33544def48115 Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Wed, 17 Dec 2025 21:51:43 -0800 Subject: [PATCH 9/9] fix(keyboard): correct row width calculation and simplify centering - Fix rowWidth calculation to properly count gaps between keys instead of using total width units - Simplify keyboard centering in KeyboardPane by using center alignment and frame width instead of manual spacer calculations - Remove complex left/right offset logic for cleaner layout code This fixes spacing issues where keyboard rows were misaligned. --- .../KeyboardLayout/KeyboardLayoutModel.swift | 3 +- Leader Key/Settings/KeyboardPane.swift | 29 +++++-------------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift b/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift index efd54e8b..c57fc887 100644 --- a/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift +++ b/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift @@ -84,7 +84,8 @@ enum KeyboardLayout { /// Calculate width of a row static func rowWidth(_ row: [KeyDefinition]) -> CGFloat { let totalUnits = row.reduce(0) { $0 + $1.width } - return totalUnits * keySize + (totalUnits - 1) * keySpacing + let totalGaps = CGFloat(max(0, row.count - 1)) + return totalUnits * keySize + totalGaps * keySpacing } /// All keyboard rows diff --git a/Leader Key/Settings/KeyboardPane.swift b/Leader Key/Settings/KeyboardPane.swift index 63f0c0fc..add9602e 100644 --- a/Leader Key/Settings/KeyboardPane.swift +++ b/Leader Key/Settings/KeyboardPane.swift @@ -544,17 +544,10 @@ struct KeyboardPane: View { var body: some View { Settings.Container(contentWidth: contentWidth) { Settings.Section(title: "", bottomDivider: true) { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .center, spacing: 12) { // Breadcrumbs & Header HStack(spacing: 0) { - // Calculate keyboard left offset to align breadcrumbs with ` key let keyboardWidth = KeyboardLayout.totalWidth * maxKeyboardScale - let centerOffset = (contentWidth - keyboardWidth) / 2 - let leftOffset = max(0, centerOffset - 4) - let rightOffset = max(0, centerOffset - 4) - - Spacer() - .frame(width: leftOffset) HStack(spacing: 4) { if !currentGroupPath.isEmpty { @@ -616,20 +609,16 @@ struct KeyboardPane: View { Spacer() } } - - Spacer() - .frame(width: rightOffset) + .frame(width: keyboardWidth) } .frame(height: 24) - HStack(spacing: 0) { - Spacer() - KeyboardLayoutView( - bindings: bindings, - isEditable: false, - shiftHeld: shiftHeld || stickyShiftMode, - scale: maxKeyboardScale, - onKeySelected: { key in + KeyboardLayoutView( + bindings: bindings, + isEditable: false, + shiftHeld: shiftHeld || stickyShiftMode, + scale: maxKeyboardScale, + onKeySelected: { key in if key == "shift" || key == "shift_r" { stickyShiftMode.toggle() return @@ -671,8 +660,6 @@ struct KeyboardPane: View { } } ) - Spacer() - } Text("Click any key to configure") .font(.body)