From 95b511a8285abcc8cb7f729d711d88d2fd9aeaef Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 10 Jan 2026 13:36:47 -0800 Subject: [PATCH 1/5] fix: remove key borders during fade transitions Remove borders completely during both per-key release fade and global overlay fade. Keys now rely entirely on shadows for separation at all times, maintaining the clean borderless aesthetic even during transitions. Changes: - keyStroke: Always returns opacity 0 (no borders ever) - strokeWidth: Always returns 0 (no stroke width ever) User preference: cleaner look without any outlines, including fade states. --- .../UI/Overlay/OverlayKeycapView.swift | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift index a344ffe0..4ac519c5 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift @@ -1643,18 +1643,14 @@ struct OverlayKeycapView: View { } private var keyStroke: Color { - // No persistent border - keys rely on shadows for separation - // (User preference: cleaner look without outlines) - if isReleaseFading { - Color.white.opacity(0) - } else { - Color.white.opacity(0.35 * fadeAmount) - } + // No borders at any time - keys rely on shadows for separation + // (User preference: cleaner look without outlines, including during fade) + Color.white.opacity(0) } private var strokeWidth: CGFloat { - // No persistent border stroke width - isReleaseFading ? 0 : fadeAmount * scale + // No border stroke width at any time + 0 } private var shadowColor: Color { From 78829c7ee6001a6dc3aac92905f398ec9d7fbbf4 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 10 Jan 2026 17:41:08 -0800 Subject: [PATCH 2/5] feat: Add SF Symbol icons, tooltips, and dynamic text sizing for layer labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer action labels (e.g., window snapping, navigation) are now readable and discoverable. Previously, labels were truncated ("M...", "Ya...") due to limited keycap space, making layers difficult to learn and use. Changes: - Add SF Symbol mapping for 30+ common actions (window management, navigation, text editing, search) - Add hover tooltips showing full action descriptions on all layer keys - Add dynamic font sizing + multi-line wrapping for labels without SF Symbols - Fix layer filtering: collections/rules now only apply to their target layer (prevents Window Snapping labels appearing on Nav layer) - Remove redundant "App Launcher" sequence preset (use Quick Launcher drawer) SF Symbol coverage: - Window halves/thirds/corners (rectangle symbols) - Display/space movement (arrows with lines/squares) - Text editing (copy, paste, delete, undo/redo) - Navigation (arrow symbols) Fallback text labels dynamically shrink from 10pt β†’ 8pt β†’ 6pt based on length, wrap to 2 lines, and use minimumScaleFactor for extreme cases. Related: #118 (floating HUD for future enhancement) Related: #119 (cache invalidation for toggled collections) Co-Authored-By: Claude Opus 4.5 --- .../Models/SequencesConfig.swift | 14 +- .../KeyboardVisualizationViewModel.swift | 38 ++++- .../LiveKeyboardOverlayController.swift | 8 + .../UI/Overlay/OverlayKeycapView.swift | 161 +++++++++++++++++- .../UI/Rules/SequencesModalView.swift | 4 - .../KeyboardVisualizationViewModelTests.swift | 1 + 6 files changed, 202 insertions(+), 24 deletions(-) diff --git a/Sources/KeyPathAppKit/Models/SequencesConfig.swift b/Sources/KeyPathAppKit/Models/SequencesConfig.swift index 5f8c21aa..f2d8bb36 100644 --- a/Sources/KeyPathAppKit/Models/SequencesConfig.swift +++ b/Sources/KeyPathAppKit/Models/SequencesConfig.swift @@ -41,12 +41,11 @@ public struct SequencesConfig: Codable, Equatable, Sendable { // MARK: - Preset Factory - /// Default preset sequences (Window Management, App Launcher, Navigation) + /// Default preset sequences (Window Management, Navigation) public static var defaultPresets: SequencesConfig { SequencesConfig( sequences: [ .windowManagementPreset, - .appLauncherPreset, .navigationPreset ], globalTimeout: 500 @@ -140,17 +139,6 @@ public struct SequenceDefinition: Codable, Equatable, Sendable, Identifiable { ) } - /// Preset: App Launcher (Space β†’ A) - public static var appLauncherPreset: SequenceDefinition { - SequenceDefinition( - id: UUID(uuidString: "5EEE0000-0000-0000-0000-000000000002")!, - name: "App Launcher", - keys: ["space", "a"], - action: .activateLayer(.custom("launcher")), - description: "Activate app quick launch layer" - ) - } - /// Preset: Navigation (Space β†’ N) public static var navigationPreset: SequenceDefinition { SequenceDefinition( diff --git a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift index de35c48d..803a228e 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift @@ -652,15 +652,22 @@ class KeyboardVisualizationViewModel: ObservableObject { layout: layout ) + // DEBUG: Log what simulator returned + AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Simulator returned \(mapping.count) entries for '\(currentLayerName)'") + for (keyCode, info) in mapping.prefix(20) { + AppLogger.shared.debug(" [\(currentLayerName)] keyCode \(keyCode) -> '\(info.displayLabel)'") + } + // Augment mapping with push-msg actions from custom rules and rule collections - // Include base layer so app/system/URL icons display for remapped keys + // Only include actions targeting this specific layer let customRules = await CustomRulesStore.shared.loadRules() let ruleCollections = await RuleCollectionStore.shared.loadCollections() AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Augmenting '\(currentLayerName)' with \(customRules.count) custom rules and \(ruleCollections.count) collections") mapping = augmentWithPushMsgActions( mapping: mapping, customRules: customRules, - ruleCollections: ruleCollections + ruleCollections: ruleCollections, + currentLayerName: currentLayerName ) // Apply app-specific overrides for the current frontmost app @@ -714,11 +721,13 @@ class KeyboardVisualizationViewModel: ObservableObject { /// - mapping: The base layer key mapping from the simulator /// - customRules: Custom rules to check for push-msg patterns /// - ruleCollections: Preset rule collections to check for push-msg patterns + /// - currentLayerName: The layer name to filter collections/rules by (only include matching layers) /// - Returns: Mapping with action info added where applicable private func augmentWithPushMsgActions( mapping: [UInt16: LayerKeyInfo], customRules: [CustomRule], - ruleCollections: [RuleCollection] + ruleCollections: [RuleCollection], + currentLayerName: String ) -> [UInt16: LayerKeyInfo] { var augmented = mapping @@ -726,7 +735,21 @@ class KeyboardVisualizationViewModel: ObservableObject { var actionByInput: [String: LayerKeyInfo] = [:] // First, process rule collections (lower priority - can be overridden by custom rules) + // Only process collections that target the current layer or base layer for collection in ruleCollections where collection.isEnabled { + // Check if this collection targets the current layer + let collectionLayerName = collection.targetLayer.kanataName.lowercased() + let currentLayer = currentLayerName.lowercased() + + // Only include mappings from collections targeting this layer + // Exception: base layer gets base-layer collections only + guard collectionLayerName == currentLayer else { + AppLogger.shared.debug("πŸ—ΊοΈ [KeyboardViz] Skipping collection '\(collection.name)' (targets '\(collectionLayerName)', current layer '\(currentLayer)')") + continue + } + + AppLogger.shared.debug("πŸ—ΊοΈ [KeyboardViz] Including collection '\(collection.name)' (\(collection.mappings.count) mappings)") + for keyMapping in collection.mappings { let input = keyMapping.input.lowercased() // First try push-msg pattern (apps, system actions, URLs) @@ -749,6 +772,15 @@ class KeyboardVisualizationViewModel: ObservableObject { // Then, process custom rules (higher priority - overrides collections) for rule in customRules where rule.isEnabled { + // Check if this rule targets the current layer + let ruleLayerName = rule.targetLayer.kanataName.lowercased() + let currentLayer = currentLayerName.lowercased() + + // Only include rules targeting this layer + guard ruleLayerName == currentLayer else { + continue + } + let input = rule.input.lowercased() // First try push-msg pattern (apps, system actions, URLs) if let info = Self.extractPushMsgInfo(from: rule.output, description: rule.notes) { diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift index 7240d4bf..50ffca52 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift @@ -419,6 +419,14 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { } } + private func bringOverlayToFront() { + if !isVisible { + isVisible = true + } + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } + /// Restore overlay state from previous session /// Only restores if system status is healthy (Kanata running) func restoreState() { diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift index 4ac519c5..45aca5d5 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift @@ -332,6 +332,7 @@ struct OverlayKeycapView: View { if key.label == "β‡ͺ" { capsLockIndicator } + } .scaleEffect(isPressed ? 0.95 : 1.0) .offset(y: isPressed && fadeAmount < 1 ? 0.75 * scale : 0) @@ -872,16 +873,168 @@ struct OverlayKeycapView: View { Image(systemName: sfSymbol) .font(.system(size: 14 * scale, weight: .medium)) .foregroundStyle(Color.white.opacity(0.9)) - } else { - // Other mapped action - show the label as text - Text(info.displayLabel.uppercased()) - .font(.system(size: 10 * scale, weight: .medium)) + .help(info.displayLabel) // Tooltip on hover + } + // Check for action-specific SF Symbol (window management, etc.) + else if let actionSymbol = sfSymbolForAction(info.displayLabel) { + Image(systemName: actionSymbol) + .font(.system(size: 14 * scale, weight: .medium)) .foregroundStyle(Color.white.opacity(0.9)) + .help(info.displayLabel) // Tooltip on hover + } else { + // No SF Symbol - use dynamic text with wrapping + dynamicTextLabel(info.displayLabel) + .help(info.displayLabel) // Tooltip on hover } } } } + /// Map action descriptions to SF Symbols + /// Returns SF Symbol name if a good match exists for the action + private func sfSymbolForAction(_ action: String) -> String? { + let lower = action.lowercased() + + // Window management - snapping to halves + if lower.contains("left") && lower.contains("half") { + return "rectangle.lefthalf.filled" + } + if lower.contains("right") && lower.contains("half") { + return "rectangle.righthalf.filled" + } + if lower.contains("top") && lower.contains("half") { + return "rectangle.tophalf.filled" + } + if lower.contains("bottom") && lower.contains("half") { + return "rectangle.bottomhalf.filled" + } + + // Window management - corners + if lower.contains("top") && lower.contains("left") && lower.contains("corner") { + return "arrow.up.left" + } + if lower.contains("top") && lower.contains("right") && lower.contains("corner") { + return "arrow.up.right" + } + if lower.contains("bottom") && lower.contains("left") && lower.contains("corner") { + return "arrow.down.left" + } + if lower.contains("bottom") && lower.contains("right") && lower.contains("corner") { + return "arrow.down.right" + } + + // Window management - maximize/fullscreen + if lower.contains("maximize") || lower.contains("fullscreen") || lower.contains("full screen") { + return "arrow.up.left.and.arrow.down.right" + } + if lower.contains("restore") { + return "arrow.down.right.and.arrow.up.left" + } + if lower.contains("center") && !lower.contains("align") { + return "circle.grid.cross" + } + + // Window management - display/monitor movement + if lower.contains("next display") || lower.contains("display right") || lower.contains("move right display") { + return "arrow.right.to.line" + } + if lower.contains("previous display") || lower.contains("display left") || lower.contains("move left display") { + return "arrow.left.to.line" + } + + // Window management - space/desktop movement + if lower.contains("next space") || lower.contains("space right") { + return "arrow.right.square" + } + if lower.contains("previous space") || lower.contains("space left") { + return "arrow.left.square" + } + + // Window management - thirds + if lower.contains("left third") || lower.contains("left 1/3") { + return "rectangle.leadinghalf.filled" + } + if lower.contains("center third") || lower.contains("middle third") { + return "rectangle.center.inset.filled" + } + if lower.contains("right third") || lower.contains("right 1/3") { + return "rectangle.trailinghalf.filled" + } + + // Window management - two-thirds + if lower.contains("left two thirds") || lower.contains("left 2/3") { + return "rectangle.leadingthird.inset.filled" + } + if lower.contains("right two thirds") || lower.contains("right 2/3") { + return "rectangle.trailingthird.inset.filled" + } + + // Navigation - directional (when not already arrows) + if lower == "up" || lower == "move up" { + return "arrow.up" + } + if lower == "down" || lower == "move down" { + return "arrow.down" + } + if lower == "left" || lower == "move left" { + return "arrow.left" + } + if lower == "right" || lower == "move right" { + return "arrow.right" + } + + // Common text editing actions + if lower.contains("yank") || lower.contains("copy") { + return "doc.on.doc" + } + if lower.contains("paste") { + return "doc.on.clipboard" + } + if lower.contains("delete") || lower.contains("remove") { + return "trash" + } + if lower.contains("undo") { + return "arrow.uturn.backward" + } + if lower.contains("redo") { + return "arrow.uturn.forward" + } + if lower.contains("save") { + return "square.and.arrow.down" + } + + // Search/Find + if lower.contains("search") || lower.contains("find") { + return "magnifyingglass" + } + + // No good SF Symbol match + return nil + } + + /// Render text label with dynamic sizing and multi-line wrapping + @ViewBuilder + private func dynamicTextLabel(_ text: String) -> some View { + GeometryReader { geometry in + let availableWidth = geometry.size.width - 4 * scale + let availableHeight = geometry.size.height - 4 * scale + let preferredSize: CGFloat = 10 * scale + let mediumSize: CGFloat = 8 * scale + let smallSize: CGFloat = 6 * scale + let estimatedWidth = CGFloat(text.count) * preferredSize * 0.6 + let fontSize = estimatedWidth <= availableWidth ? preferredSize : (estimatedWidth <= availableWidth * 1.5 ? mediumSize : smallSize) + + Text(text.uppercased()) + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.9)) + .lineLimit(2) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.5) + .frame(maxWidth: availableWidth, maxHeight: availableHeight) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + // MARK: - Legend Style: Dots /// Renders a colored dot/circle instead of text legend (GMK Dots style) diff --git a/Sources/KeyPathAppKit/UI/Rules/SequencesModalView.swift b/Sources/KeyPathAppKit/UI/Rules/SequencesModalView.swift index 15122602..bbe1afb3 100644 --- a/Sources/KeyPathAppKit/UI/Rules/SequencesModalView.swift +++ b/Sources/KeyPathAppKit/UI/Rules/SequencesModalView.swift @@ -125,10 +125,6 @@ struct SequencesModalView: View { addPreset(.windowManagementPreset) } .accessibilityIdentifier("sequences-modal-preset-window") - Button("App Launcher") { - addPreset(.appLauncherPreset) - } - .accessibilityIdentifier("sequences-modal-preset-launcher") Button("Navigation") { addPreset(.navigationPreset) } diff --git a/Tests/KeyPathTests/UI/KeyboardVisualizationViewModelTests.swift b/Tests/KeyPathTests/UI/KeyboardVisualizationViewModelTests.swift index 0554886d..e1b3cf8d 100644 --- a/Tests/KeyPathTests/UI/KeyboardVisualizationViewModelTests.swift +++ b/Tests/KeyPathTests/UI/KeyboardVisualizationViewModelTests.swift @@ -547,4 +547,5 @@ final class KeyboardVisualizationViewModelTests: XCTestCase { XCTAssertNil(LogicalKeymap.keyCode(forQwertyLabel: "")) XCTAssertNil(LogicalKeymap.keyCode(forQwertyLabel: "space")) } + } From ceb8f8d6d5307198865b0fc9407ae1bc7b9601b1 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 10 Jan 2026 18:46:47 -0800 Subject: [PATCH 3/5] fix: Prevent transparent keys from being augmented with action labels Transparent keys (XX in config) were incorrectly showing action labels from rule collections, making them appear "mapped" when they should pass through to the base layer. Root cause: augmentWithPushMsgActions() was replacing LayerKeyInfo for ALL keys that matched collection/rule inputs, including transparent keys. The replacement LayerKeyInfo (created via .mapped(), .systemAction(), etc.) had isTransparent hardcoded to false, losing the transparency flag from the simulator. Fix: Skip augmentation for keys where originalInfo.isTransparent == true. Only augment keys that actually have mappings on the current layer. Example: On Nav layer, symbol keys like [, ], \, numbers are XX (transparent). Previously they showed orange highlights incorrectly. Now they remain dark. Co-Authored-By: Claude Opus 4.5 --- .../KeyboardVisualizationViewModel.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift index 803a228e..80bc02e0 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift @@ -811,9 +811,17 @@ class KeyboardVisualizationViewModel: ObservableObject { AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Found \(actionByInput.count) actions (push-msg + simple remaps)") // Update mapping entries - for (keyCode, _) in mapping { + // IMPORTANT: Only augment keys that are NOT transparent (XX) + // Transparent keys should pass through without showing action labels + for (keyCode, originalInfo) in mapping { let keyName = OverlayKeyboardView.keyCodeToKanataName(keyCode).lowercased() if let info = actionByInput[keyName] { + // Skip augmentation if the original key is transparent (XX) + // Transparent keys should not show action labels from collections/rules + if originalInfo.isTransparent { + AppLogger.shared.debug("πŸ—ΊοΈ [KeyboardViz] Skipping augmentation for transparent key \(keyName)(\(keyCode))") + continue + } augmented[keyCode] = info AppLogger.shared.debug("πŸ—ΊοΈ [KeyboardViz] Key \(keyName)(\(keyCode)) -> '\(info.displayLabel)'") } From 01c197711612cb882be383bfa1fb33767c08c6d4 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 10 Jan 2026 18:54:18 -0800 Subject: [PATCH 4/5] fix: Mark keys with no simulator outputs as transparent on non-base layers Transparent keys (XX in config) were being incorrectly marked as isTransparent=false when the simulator returned no output events, causing them to appear highlighted despite being transparent. Root cause: LayerKeyMapper.parseRawSimulationEvents() returns nil when there are no output events (the correct behavior for XX keys). However, the caller was creating LayerKeyInfo with isTransparent hardcoded to false for these nil results. Fix: On non-base layers, no simulator outputs = transparent (XX). Set isTransparent=true when parseRawSimulationEvents returns nil on layers other than base. Result: Symbol keys ([, ], \, numbers, etc.) now correctly appear dark on Nav layer instead of being highlighted orange. Tested: Verified on Nav layer - only actual Vim mappings (H/J/K/L, etc.) are highlighted, while transparent keys remain dark. Co-Authored-By: Claude Opus 4.5 --- Sources/KeyPathAppKit/Services/LayerKeyMapper.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/KeyPathAppKit/Services/LayerKeyMapper.swift b/Sources/KeyPathAppKit/Services/LayerKeyMapper.swift index 7fc3f068..7e1057d8 100644 --- a/Sources/KeyPathAppKit/Services/LayerKeyMapper.swift +++ b/Sources/KeyPathAppKit/Services/LayerKeyMapper.swift @@ -403,14 +403,20 @@ actor LayerKeyMapper { // Parse events to extract outputs guard let parsed = parseRawSimulationEvents(simName: simName, events: result.events) else { - // No outputs found - use physical label + // No outputs found + // On non-base layers: this means the key is transparent (XX) + // On base layer: use physical label (shouldn't happen, but handle gracefully) + let isTransparent = startLayer.lowercased() != "base" mapping[keyCode] = LayerKeyInfo( displayLabel: fallbackLabel, outputKey: nil, outputKeyCode: nil, - isTransparent: false, + isTransparent: isTransparent, isLayerSwitch: false ) + if isTransparent { + AppLogger.shared.debug("πŸ” [LayerKeyMapper] \(simName)(\(keyCode)) is transparent (XX) on '\(startLayer)'") + } continue } From 90edbbe00623478529374029f8106ed4d4dae486 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Sat, 10 Jan 2026 21:05:02 -0800 Subject: [PATCH 5/5] fix: Improve Nav layer overlay consistency and TouchID icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace TouchID lock emoji with drawer icon (sidebar.right) across all layers - Prevent bottom row modifiers (fn, ctrl, opt, cmd) from showing as mapped in layer modes - Render unmapped Nav layer keys with lowercase text labels (tab, shift, delete, return) - Maintain modifier symbols (βŒƒ, βŒ₯, ⌘) instead of converting to text - Position hyper symbol (✦) at bottom of caps lock key to avoid indicator overlap - Keep bottom row styling consistent between base and Nav layers - Add special fn key rendering in mapped layer mode to preserve globe + "fn" appearance Co-Authored-By: Claude Opus 4.5 --- .../KeyboardVisualizationViewModel.swift | 46 ++++--- .../UI/Overlay/LiveKeyboardOverlayView.swift | 39 ++++-- .../UI/Overlay/OverlayKeycapView.swift | 115 ++++++++++++++---- .../KeyPathAppKit/UI/RulesSummaryView.swift | 15 +-- .../KeyboardVisualizationViewModelTests.swift | 1 - 5 files changed, 157 insertions(+), 59 deletions(-) diff --git a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift index 80bc02e0..41d236d2 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift @@ -541,24 +541,31 @@ class KeyboardVisualizationViewModel: ObservableObject { /// Update the current layer and rebuild key mapping func updateLayer(_ layerName: String) { let wasLauncherMode = isLauncherModeActive - currentLayerName = layerName + + // IMPORTANT: Don't update currentLayerName yet - wait until mapping is ready + // This prevents UI flash where old mapping shows with new layer name + let targetLayerName = layerName // Clear tap-hold sources on layer change to prevent stale suppressions // (e.g., user switches layers while holding a tap-hold key) activeTapHoldSources.removeAll() + // Check if we'll be entering/exiting launcher mode + let willBeLauncherMode = targetLayerName.lowercased() == Self.launcherLayerName + // Load/clear launcher mappings when entering/exiting launcher mode - let isNowLauncherMode = isLauncherModeActive - if isNowLauncherMode, !wasLauncherMode { + if willBeLauncherMode, !wasLauncherMode { loadLauncherMappings() - } else if !isNowLauncherMode, wasLauncherMode { + } else if !willBeLauncherMode, wasLauncherMode { launcherMappings.removeAll() } // Reset idle timer on any layer change (including returning to base) noteInteraction() noteTcpEventReceived() - rebuildLayerMapping() + + // Build mapping first, then update layer name atomically when ready + rebuildLayerMappingForLayer(targetLayerName) } /// Load launcher mappings from the Quick Launcher rule collection @@ -626,6 +633,12 @@ class KeyboardVisualizationViewModel: ObservableObject { /// Rebuild the key mapping for the current layer func rebuildLayerMapping() { + rebuildLayerMappingForLayer(currentLayerName) + } + + /// Rebuild the key mapping for a specific layer + /// Updates both the layer name and mapping atomically to prevent UI flash + private func rebuildLayerMappingForLayer(_ targetLayerName: String) { // Cancel any in-flight mapping task layerMapTask?.cancel() @@ -636,7 +649,7 @@ class KeyboardVisualizationViewModel: ObservableObject { } isLoadingLayerMap = true - AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Starting layer mapping build for '\(currentLayerName)'...") + AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Starting layer mapping build for '\(targetLayerName)'...") layerMapTask = Task { [weak self] in guard let self else { return } @@ -645,44 +658,47 @@ class KeyboardVisualizationViewModel: ObservableObject { let configPath = WizardSystemPaths.userConfigPath AppLogger.shared.debug("πŸ—ΊοΈ [KeyboardViz] Using config: \(configPath)") - // Build mapping for current layer + // Build mapping for target layer var mapping = try await layerKeyMapper.getMapping( - for: currentLayerName, + for: targetLayerName, configPath: configPath, layout: layout ) // DEBUG: Log what simulator returned - AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Simulator returned \(mapping.count) entries for '\(currentLayerName)'") + AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Simulator returned \(mapping.count) entries for '\(targetLayerName)'") for (keyCode, info) in mapping.prefix(20) { - AppLogger.shared.debug(" [\(currentLayerName)] keyCode \(keyCode) -> '\(info.displayLabel)'") + AppLogger.shared.debug(" [\(targetLayerName)] keyCode \(keyCode) -> '\(info.displayLabel)'") } // Augment mapping with push-msg actions from custom rules and rule collections // Only include actions targeting this specific layer let customRules = await CustomRulesStore.shared.loadRules() let ruleCollections = await RuleCollectionStore.shared.loadCollections() - AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Augmenting '\(currentLayerName)' with \(customRules.count) custom rules and \(ruleCollections.count) collections") + AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Augmenting '\(targetLayerName)' with \(customRules.count) custom rules and \(ruleCollections.count) collections") mapping = augmentWithPushMsgActions( mapping: mapping, customRules: customRules, ruleCollections: ruleCollections, - currentLayerName: currentLayerName + currentLayerName: targetLayerName ) // Apply app-specific overrides for the current frontmost app mapping = await applyAppSpecificOverrides(to: mapping) - // Update on main actor with explicit objectWillChange to ensure SwiftUI notices + // Update layer name and mapping atomically to prevent UI flash + // This ensures the UI never shows mismatched layer name + old mapping await MainActor.run { self.objectWillChange.send() + self.currentLayerName = targetLayerName self.layerKeyMap = mapping self.remapOutputMap = self.buildRemapOutputMap(from: mapping) self.isLoadingLayerMap = false - AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Updated layerKeyMap with \(mapping.count) entries, remapOutputMap with \(self.remapOutputMap.count) remaps") + AppLogger.shared + .info("πŸ—ΊοΈ [KeyboardViz] Updated currentLayerName to '\(targetLayerName)' and layerKeyMap with \(mapping.count) entries, remapOutputMap with \(self.remapOutputMap.count) remaps") } - AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Built layer mapping for '\(currentLayerName)': \(mapping.count) keys") + AppLogger.shared.info("πŸ—ΊοΈ [KeyboardViz] Built layer mapping for '\(targetLayerName)': \(mapping.count) keys") // Log a few sample mappings for debugging for (keyCode, info) in mapping.prefix(5) { diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView.swift index aa06fe7c..1054fbb5 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView.swift @@ -991,15 +991,13 @@ private struct OverlayDragHeader: View { Spacer() // Controls aligned to the right side of the header - // Order: Status indicators (left) β†’ [spacer] β†’ Drawer β†’ Close (far right) + // Order: Status indicators (left) β†’ Drawer β†’ Close (far right) HStack(spacing: 6) { // 1. Status slot (leftmost of the right-aligned group): // - Shows health indicator when not dismissed (including the "Ready" pill) // - Otherwise shows Japanese input + layer pill statusSlot(indicatorCornerRadius: indicatorCornerRadius) - Spacer() - // 2. Toggle inspector/drawer button Button { AppLogger.shared.log("πŸ”˜ [Header] Toggle drawer button clicked - isInspectorOpen=\(isInspectorOpen)") @@ -1033,7 +1031,6 @@ private struct OverlayDragHeader: View { .accessibilityIdentifier("overlay-close-button") .accessibilityLabel("Close keyboard overlay") } - .frame(width: maxControlsWidth, alignment: .leading) .padding(.trailing, 6) .animation(.easeOut(duration: 0.12), value: currentLayerName) .animation(.spring(response: 0.4, dampingFraction: 0.7), value: healthIndicatorState) @@ -1107,13 +1104,16 @@ private struct OverlayDragHeader: View { layerDisplayName: layerDisplayName, indicatorCornerRadius: indicatorCornerRadius ) + .id(layerDisplayName) // Force new view when layer changes + .transition(.move(edge: .top)) + .animation(.easeOut(duration: 0.2), value: layerDisplayName) } } - .transition(.opacity.combined(with: .scale)) + .transition(.opacity) } } - // Keep the slot leading-aligned so the status indicators are on the left of the controls. - .frame(maxWidth: .infinity, alignment: .leading) + // Don't expand to fill space - let Nav pill stay close to drawer button + .fixedSize(horizontal: true, vertical: false) } private func inputModePill(indicator: String, indicatorCornerRadius: CGFloat) -> some View { @@ -1139,8 +1139,10 @@ private struct OverlayDragHeader: View { } private func layerPill(layerDisplayName: String, indicatorCornerRadius: CGFloat) -> some View { - HStack(spacing: 4) { - Image(systemName: "square.3.layers.3d") + let iconName = layerIconName(for: layerDisplayName) + + return HStack(spacing: 4) { + Image(systemName: iconName) .font(.system(size: 9, weight: .medium)) Text(layerDisplayName) .font(.system(size: 10, weight: .semibold, design: .monospaced)) @@ -1157,6 +1159,25 @@ private struct OverlayDragHeader: View { .accessibilityLabel("Current layer: \(layerDisplayName)") } + private func layerIconName(for layerDisplayName: String) -> String { + let lower = layerDisplayName.lowercased() + + switch lower { + case "nav", "navigation", "vim": + return "arrow.up.and.down.and.arrow.left.and.right" + case "window", "window-mgmt": + return "macwindow" + case "numpad", "num": + return "number" + case "sym", "symbol": + return "character" + case "launcher", "quick launcher": + return "app.badge" + default: + return "square.3.layers.3d" + } + } + private func kanataDisconnectedPill(indicatorCornerRadius: CGFloat) -> some View { HStack(spacing: 4) { Image(systemName: "antenna.radiowaves.left.and.right.slash") diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift index 45aca5d5..801468e0 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayKeycapView.swift @@ -76,6 +76,11 @@ struct OverlayKeycapView: View { /// Whether this key has a meaningful layer mapping (not transparent/identity) private var hasLayerMapping: Bool { guard let info = layerKeyInfo else { return false } + // Bottom row modifier keys (fn, ctrl, opt, cmd) should never show as mapped in layer modes + // They are fundamental modifiers that should look consistent across all layers + if key.layoutRole == .narrowModifier { + return false + } // Has a mapping if it's not transparent and has actual content if info.isTransparent { return false } if info.isLayerSwitch { return true } @@ -332,7 +337,6 @@ struct OverlayKeycapView: View { if key.label == "β‡ͺ" { capsLockIndicator } - } .scaleEffect(isPressed ? 0.95 : 1.0) .offset(y: isPressed && fadeAmount < 1 ? 0.75 * scale : 0) @@ -529,8 +533,12 @@ struct OverlayKeycapView: View { /// Standard key content routing (used for .standard legend style) @ViewBuilder private var standardKeyContent: some View { + // TouchID/Power key: ALWAYS show drawer icon regardless of mode + if key.keyCode == 0xFFFF { + touchIdContent + } // Launcher mode: ALL keys use launcher styling (icons for mapped, labels for unmapped) - if isLauncherMode { + else if isLauncherMode { launcherModeContent } // Layer mode (Vim/Nav): ALL keys use layer styling (action in center, label in top-left) @@ -796,33 +804,62 @@ struct OverlayKeycapView: View { let isArrowKey = key.layoutRole == .arrow if hasLayerMapping { - // Mapped key: action in center, key letter in top-left (except arrows) - ZStack(alignment: .topLeading) { - // Centered action content - layerActionContent - .frame(maxWidth: .infinity, maxHeight: .infinity) + // Special case: fn key should always show globe + "fn" even when mapped + if key.label == "fn" { + fnKeyContent + } else { + // Mapped key: action in center, key letter in top-left (except arrows) + ZStack(alignment: .topLeading) { + // Centered action content + layerActionContent + .frame(maxWidth: .infinity, maxHeight: .infinity) - // Key letter in top-left corner (skip for arrow keys to avoid dual arrows) - if !isArrowKey { - Text(layerKeyLabel.uppercased()) - .font(.system(size: 8 * scale, weight: .medium, design: .rounded)) - .foregroundStyle(Color.white.opacity(0.7)) - .padding(3 * scale) + // Key letter in top-left corner (skip for arrow keys to avoid dual arrows) + if !isArrowKey { + Text(layerKeyLabel.uppercased()) + .font(.system(size: 8 * scale, weight: .medium, design: .rounded)) + .foregroundStyle(Color.white.opacity(0.7)) + .padding(3 * scale) + } } } } else { - // Unmapped key in layer mode: small label in top-left (skip for arrows) + // Unmapped key in layer mode: small label in top-left (skip for arrows and bottom row modifiers) if isArrowKey { // Arrow keys: just show centered arrow arrowContent + } else if key.layoutRole == .narrowModifier { + // Bottom row modifiers (fn, ctrl, opt, cmd): render same as base layer + narrowModifierContent } else { - ZStack(alignment: .topLeading) { - Text(layerKeyLabel.uppercased()) + // In Nav layer, convert symbols to text labels (except modifier keys) + let displayLabel: String = { + if currentLayerName.lowercased() == "nav" { + // Keep modifier symbols (βŒƒ, βŒ₯, ⌘) as-is, convert others to text + let modifierSymbols: Set = ["βŒƒ", "βŒ₯", "⌘", "fn"] + if modifierSymbols.contains(layerKeyLabel) { + return layerKeyLabel + } + let physicalMetadata = LabelMetadata.forLabel(layerKeyLabel) + return physicalMetadata.wordLabel ?? layerKeyLabel + } + return layerKeyLabel + }() + + // Keep letter keys uppercase, but word labels (tab, shift, etc.) lowercase + let finalLabel = displayLabel.count > 2 ? displayLabel : displayLabel.uppercased() + + // Caps lock (hyper key) shows ✦ at bottom to avoid overlapping with indicator light + let isCapsLock = key.keyCode == 57 + let alignment: Alignment = isCapsLock ? .bottomLeading : .topLeading + + ZStack(alignment: alignment) { + Text(finalLabel) .font(.system(size: 8 * scale, weight: .medium, design: .rounded)) .foregroundStyle(Color.white.opacity(0.5)) .padding(3 * scale) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) } } } @@ -868,16 +905,20 @@ struct OverlayKeycapView: View { .font(.system(size: 16 * scale, weight: .semibold)) .foregroundStyle(Color.white.opacity(0.9)) } else if !info.displayLabel.isEmpty { - // Check for SF symbol (media keys, system actions) - if let sfSymbol = LabelMetadata.sfSymbol(forOutputLabel: info.displayLabel) { - Image(systemName: sfSymbol) + // Skip SF symbols for modifier/special keys - keep text labels + let skipSymbolConversion = isModifierOrSpecialKey(info.displayLabel) + + // Check for action-specific SF Symbol (window management, etc.) + // But skip if it's a modifier/special key + if !skipSymbolConversion, let actionSymbol = sfSymbolForAction(info.displayLabel) { + Image(systemName: actionSymbol) .font(.system(size: 14 * scale, weight: .medium)) .foregroundStyle(Color.white.opacity(0.9)) .help(info.displayLabel) // Tooltip on hover } - // Check for action-specific SF Symbol (window management, etc.) - else if let actionSymbol = sfSymbolForAction(info.displayLabel) { - Image(systemName: actionSymbol) + // Check for SF symbol (media keys, system actions) + else if !skipSymbolConversion, let sfSymbol = LabelMetadata.sfSymbol(forOutputLabel: info.displayLabel) { + Image(systemName: sfSymbol) .font(.system(size: 14 * scale, weight: .medium)) .foregroundStyle(Color.white.opacity(0.9)) .help(info.displayLabel) // Tooltip on hover @@ -890,6 +931,28 @@ struct OverlayKeycapView: View { } } + /// Check if a label represents a modifier or special key that should keep text labels + /// instead of being converted to SF symbols + private func isModifierOrSpecialKey(_ label: String) -> Bool { + let lower = label.lowercased() + let modifierKeys: Set = [ + "shift", "lshift", "rshift", "leftshift", "rightshift", + "control", "ctrl", "lctrl", "rctrl", "leftcontrol", "rightcontrol", + "option", "opt", "alt", "lalt", "ralt", "leftoption", "rightoption", + "command", "cmd", "meta", "lmet", "rmet", "leftcommand", "rightcommand", + "hyper", "meh", + "capslock", "caps", + "return", "enter", "ret", + "escape", "esc", + "tab", + "space", "spc", + "backspace", "bspc", + "delete", "del", + "fn", "function" + ] + return modifierKeys.contains(lower) + } + /// Map action descriptions to SF Symbols /// Returns SF Symbol name if a good match exists for the action private func sfSymbolForAction(_ action: String) -> String? { @@ -1515,6 +1578,10 @@ struct OverlayKeycapView: View { if let holdLabel { return holdLabel } + // In Nav layer, always use text labels (not symbols) for unmapped keys + if currentLayerName.lowercased() == "nav" { + return physicalMetadata.wordLabel ?? key.label + } return metadata.wordLabel ?? physicalMetadata.wordLabel ?? key.label }() let isRight = key.isRightSideKey @@ -1700,7 +1767,7 @@ struct OverlayKeycapView: View { .modifier(PulseAnimation()) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - // Large centered sidebar icon + // Always show drawer icon (sidebar.right opens the inspector drawer) Image(systemName: "sidebar.right") .font(.system(size: 12 * scale, weight: .regular)) .foregroundStyle(foregroundColor) diff --git a/Sources/KeyPathAppKit/UI/RulesSummaryView.swift b/Sources/KeyPathAppKit/UI/RulesSummaryView.swift index c1f4b3f0..2849b2bc 100644 --- a/Sources/KeyPathAppKit/UI/RulesSummaryView.swift +++ b/Sources/KeyPathAppKit/UI/RulesSummaryView.swift @@ -2075,8 +2075,7 @@ private struct OutputKeyboardWithAnimatedSymbols: View { /// Get the target frame for a symbol - either its mapped key position or the parking area private func targetFrameFor(_ symbol: String) -> CGRect { if let targetKey = symbolTargets[symbol], - let frame = keycapFrames[targetKey] - { + let frame = keycapFrames[targetKey] { return frame } return parkingFrame @@ -2563,8 +2562,7 @@ private struct TapHoldPickerContent: View { /// Get display label for a custom value (system action or key) private func displayLabelFor(_ value: String) -> String { if let actionId = CustomRuleValidator.extractSystemActionId(from: value), - let action = CustomRuleValidator.systemAction(for: actionId) - { + let action = CustomRuleValidator.systemAction(for: actionId) { return action.name } return value @@ -2573,8 +2571,7 @@ private struct TapHoldPickerContent: View { /// Get SF Symbol for a custom value if it's a system action private func sfSymbolFor(_ value: String) -> String? { if let actionId = CustomRuleValidator.extractSystemActionId(from: value), - let action = CustomRuleValidator.systemAction(for: actionId) - { + let action = CustomRuleValidator.systemAction(for: actionId) { return action.sfSymbol } return nil @@ -2783,8 +2780,7 @@ private struct CustomKeyPopover: View { /// Display label for the current input (shows friendly name for system actions) private var displayLabel: String { if let actionId = CustomRuleValidator.extractSystemActionId(from: keyInput), - let action = CustomRuleValidator.systemAction(for: actionId) - { + let action = CustomRuleValidator.systemAction(for: actionId) { return action.name } return keyInput @@ -3842,8 +3838,7 @@ private struct AppLaunchChip: View { // Get app name from bundle if let bundle = Bundle(url: url), - let name = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String - { + let name = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String { appName = name } else { // Use filename without extension diff --git a/Tests/KeyPathTests/UI/KeyboardVisualizationViewModelTests.swift b/Tests/KeyPathTests/UI/KeyboardVisualizationViewModelTests.swift index e1b3cf8d..0554886d 100644 --- a/Tests/KeyPathTests/UI/KeyboardVisualizationViewModelTests.swift +++ b/Tests/KeyPathTests/UI/KeyboardVisualizationViewModelTests.swift @@ -547,5 +547,4 @@ final class KeyboardVisualizationViewModelTests: XCTestCase { XCTAssertNil(LogicalKeymap.keyCode(forQwertyLabel: "")) XCTAssertNil(LogicalKeymap.keyCode(forQwertyLabel: "space")) } - }