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..832205b5 100644 --- a/Leader Key/Cheatsheet.swift +++ b/Leader Key/Cheatsheet.swift @@ -3,195 +3,114 @@ 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 + } + .frame(width: CheatsheetView.preferredWidth) + .frame(height: min(contentHeight, maxHeight)) + .background( + ZStack { + VisualEffectView(material: .popover, blendingMode: .behindWindow) + Color(NSColor.windowBackgroundColor).opacity(0.7) } + ) + .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() - } - } - - 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,90 @@ class Controller { mainWindow.cheatsheetOrigin(cheatsheetSize: cheatsheet.frame.size)) } - private func showCheatsheet() { + private func centerCheatsheetWindow() { + guard let cheatsheet = cheatsheetWindow, + let screen = cheatsheetDisplayScreen() + else { + return + } + + let screenCenter = screen.center() + let cheatsheetFrame = cheatsheet.frame + + // 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)) + } + + 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() - cheatsheetWindow?.orderFront(nil) + + 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() + cheatsheetWindow?.orderFront(nil) + + // 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) + } + } + + private func exitCenteredMode() { + if isCheatsheetCentered { + window.alphaValue = 1 + isCheatsheetCentered = false + userState.cheatsheetCentered = false + cheatsheetWindow?.orderOut(nil) + } } private func scheduleCheatsheet() { @@ -284,7 +385,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 +463,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..977fa2c8 --- /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: true) + } + .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..d2a1ef45 --- /dev/null +++ b/Leader Key/KeyboardLayout/KeyboardCheatsheetView.swift @@ -0,0 +1,87 @@ +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) { + // 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() + } + .frame(height: 28 * scale) + + // Keyboard layout + KeyboardLayoutView( + bindings: bindings, + isEditable: false, + shiftHeld: userState.shiftHeld, + scale: scale + ) + } + .padding(16 * scale) + .background( + ZStack { + VisualEffectView(material: .popover, blendingMode: .behindWindow) + Color(NSColor.windowBackgroundColor).opacity(0.7) + } + ) + } +} + +#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..c57fc887 --- /dev/null +++ b/Leader Key/KeyboardLayout/KeyboardLayoutModel.swift @@ -0,0 +1,177 @@ +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 } + let totalGaps = CGFloat(max(0, row.count - 1)) + return totalUnits * keySize + totalGaps * 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 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.0), + KeyDefinition(label: "⌘", key: "cmd_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), + ] +} 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..add9602e --- /dev/null +++ b/Leader Key/Settings/KeyboardPane.swift @@ -0,0 +1,832 @@ +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 + + @Environment(\.dismiss) private var dismiss + @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 + Spacer() + } + } + .padding(.horizontal, 24) + + Spacer() + + // Footer + HStack { + 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: { + Image(systemName: "trash") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .help("Delete Item") + } + + Spacer() + + Button(selectedType == .group ? "Assign Actions" : "Done") { + let updated = getUpdatedItem() + item = updated + onSave(updated) + if selectedType == .group { + onOpenLayout(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: .center, spacing: 12) { + // Breadcrumbs & Header + HStack(spacing: 0) { + let keyboardWidth = KeyboardLayout.totalWidth * maxKeyboardScale + + 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) + } + } + } + } + } + + 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: { + Image(systemName: "house.fill") + } + .opacity(0) + .disabled(true) + + Spacer() + } + } + .frame(width: keyboardWidth) + } + .frame(height: 24) + + 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 + ) + } + } + ) + + 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 + } } 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 }