Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 1 addition & 13 deletions Sources/KeyPathAppKit/Models/SequencesConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 8 additions & 2 deletions Sources/KeyPathAppKit/Services/LayerKeyMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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 }
Expand All @@ -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
Comment on lines 691 to 694

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard against stale layer-map tasks overwriting state

Because rebuildLayerMappingForLayer now captures targetLayerName and updates currentLayerName only when the async task finishes, a canceled/slow task can still complete and overwrite the UI with an older layer if the user switches layers quickly (cancellation is cooperative and LayerKeyMapper.getMapping doesn’t check it). This can leave the overlay showing the wrong layer/mapping after rapid layer changes. Consider checking Task.isCancelled (or comparing against a “latest requested layer” token) before applying results on the MainActor.

Useful? React with 👍 / 👎.

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")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancelled task may still update layer state causing inconsistency

Medium Severity

The rebuildLayerMappingForLayer function cancels the previous task at line 643 but the MainActor.run block that updates currentLayerName and layerKeyMap has no Task.isCancelled check. Swift task cancellation is cooperative, so a cancelled task can still complete its scheduled work. With rapid layer switches, an older cancelled task may update state after a newer task, causing the overlay to display the wrong layer. The file uses Task.isCancelled checks elsewhere (lines 425, 459) but not before this critical state update.

Additional Locations (1)

Fix in Cursor Fix in Web

}

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) {
Expand Down Expand Up @@ -714,19 +737,35 @@ 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

// Build lookups from input key -> LayerKeyInfo
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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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)'")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,14 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate {
}
}

private func bringOverlayToFront() {
if !isVisible {
isVisible = true
}
NSApp.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused private method added but never called

Low Severity

The private method bringOverlayToFront() is defined but never called anywhere in the codebase. This appears to be dead code that was accidentally included in the commit, as the PR description does not mention adding this functionality. The method duplicates functionality already present in other methods like showForQuickLaunch.

Fix in Cursor Fix in Web


/// Restore overlay state from previous session
/// Only restores if system status is healthy (Kanata running)
func restoreState() {
Expand Down
39 changes: 30 additions & 9 deletions Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand All @@ -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")
Expand Down
Loading
Loading