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/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 } diff --git a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel.swift index de35c48d..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,37 +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 '\(targetLayerName)'") + for (keyCode, info) in mapping.prefix(20) { + AppLogger.shared.debug(" [\(targetLayerName)] 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") + AppLogger.shared.info("🗺️ [KeyboardViz] Augmenting '\(targetLayerName)' with \(customRules.count) custom rules and \(ruleCollections.count) collections") mapping = augmentWithPushMsgActions( mapping: mapping, customRules: customRules, - ruleCollections: ruleCollections + ruleCollections: ruleCollections, + 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) { @@ -714,11 +737,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 +751,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 +788,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) { @@ -779,9 +827,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)'") } 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/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 a344ffe0..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 } @@ -528,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) @@ -795,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) } } } @@ -867,21 +905,199 @@ struct OverlayKeycapView: View { .font(.system(size: 16 * scale, weight: .semibold)) .foregroundStyle(Color.white.opacity(0.9)) } else if !info.displayLabel.isEmpty { + // 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 SF symbol (media keys, system actions) - if let sfSymbol = LabelMetadata.sfSymbol(forOutputLabel: info.displayLabel) { + 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 } else { - // Other mapped action - show the label as text - Text(info.displayLabel.uppercased()) - .font(.system(size: 10 * scale, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.9)) + // No SF Symbol - use dynamic text with wrapping + dynamicTextLabel(info.displayLabel) + .help(info.displayLabel) // Tooltip on hover } } } } + /// 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? { + 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) @@ -1362,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 @@ -1547,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) @@ -1643,18 +1863,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 { 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/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