diff --git a/Sources/KeyPathAppKit/App.swift b/Sources/KeyPathAppKit/App.swift index 4decf839f..2209d9486 100644 --- a/Sources/KeyPathAppKit/App.swift +++ b/Sources/KeyPathAppKit/App.swift @@ -447,6 +447,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { _ = ContextHUDController.shared + // Watch frontmost-app changes and auto-hide the overlay for apps + // the user has listed in Settings β†’ Experimental (default: Figma). + OverlayAppSuppressor.shared.start() + // Sequential startup: regenerate config, auto-launch, validate, auto-wizard Task { @MainActor in do { diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift index 3b5b7586d..1cb3a67a6 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+BlockBuilders.swift @@ -135,9 +135,31 @@ extension KanataConfiguration { guard !hasLayerBasePush else { return output } if activatorKeysBySourceLayer[layer]?.contains(sourceKey) == true { return output } // Use release-layer to explicitly release the layer-while-held, then output, then notify UI. - // Modifier chords (M-v, M-S-z) work natively inside multi actions thanks to the - // keyberon multi_depth fix β€” no expansion needed. - return "(multi (release-layer \(layer.kanataName)) \(output) (push-msg \"layer:base\"))" + // `(release-layer ...)` inside a `multi` silently swallows subsequent + // modifier-prefixed tokens (e.g. C-up, M-left) in kanata's runtime, + // so we expand them to their explicit multi form first + // (`(multi lctl up)`). Plain key outputs and already-S-expression + // outputs pass through unchanged. + let expandedOutput = expandOutputForMultiWrapping(output) + return "(multi (release-layer \(layer.kanataName)) \(expandedOutput) (push-msg \"layer:base\"))" + } + + /// When a modifier-prefixed output like `C-up` or `M-S-z` needs to be + /// placed inside a `(multi ...)` action that also contains + /// `(release-layer ...)`, kanata drops the key emission on its floor. + /// Expand to the explicit `(multi mod1 mod2 key)` form in that case. + /// Leaves non-prefix outputs (`down`, `f11`, `(push-msg ...)`) alone. + func expandOutputForMultiWrapping(_ output: String) -> String { + let trimmed = output.trimmingCharacters(in: .whitespaces) + // Already an S-expression β€” pass through. + if trimmed.hasPrefix("("), trimmed.hasSuffix(")") { return trimmed } + // No modifier prefix β€” plain key. + let modifierPrefixes = ["C-", "M-", "A-", "S-", "RA-", "RM-", "RC-", "RS-", "AG-"] + guard modifierPrefixes.contains(where: { trimmed.uppercased().hasPrefix($0) }) else { + return trimmed + } + // Expand to explicit `(multi lctl up)` form. + return Self.convertSingleKeyToForkFormat(trimmed) } // Precompute mapped keys for non-base layers to avoid blocking keys mapped by other collections. @@ -700,9 +722,12 @@ extension KanataConfiguration { var remainingKey = key var modifiers: [String] = [] - // Extract all modifier prefixes (only match first one, case-insensitive) - let lowercasedKey = remainingKey.lowercased() - if let (prefix, modKey) = modifierMap.first(where: { lowercasedKey.hasPrefix($0.key.lowercased()) }) { + // Extract modifier prefixes (e.g. "C-", "M-", "C-S-"). Note: these + // are *prefixes* (the notation markers), not the lowered-case key + // names. A previous implementation mistakenly matched against the + // key-name values and therefore never expanded anything. + let uppercasedKey = remainingKey.uppercased() + if let (prefix, modKey) = modifierMap.first(where: { uppercasedKey.hasPrefix($0.prefix) }) { modifiers.append(contentsOf: modKey.split(separator: " ").map(String.init)) remainingKey = String(remainingKey.dropFirst(prefix.count)) } diff --git a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift index 8e8f3376c..d2277cb46 100644 --- a/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift @@ -110,23 +110,58 @@ final class ConfigReloadCoordinator { AppLogger.shared.debug( "πŸ“‘ [Reload] TCP reload failed: \(tcpResult.errorMessage ?? "Unknown error")" ) - NotificationCenter.default.post( - name: .configReloadFailed, - object: nil, - userInfo: [ - "message": tcpResult.errorMessage ?? "TCP reload failed", - "response": tcpResult.response ?? "", - ] - ) + let errorMessage = tcpResult.errorMessage ?? "TCP reload failed" + // Cooldown blocks are a deliberate throttle, not a real failure. + // Schedule a deferred retry so the write we just persisted + // actually reaches kanata, and suppress the user-facing toast/ + // error sound β€” the next reload attempt will fire when cooldown + // expires. Real failures (validation, network, etc.) still + // notify as before. + if isCooldownBlockMessage(errorMessage) { + scheduleDeferredReloadAfterCooldown() + } else { + NotificationCenter.default.post( + name: .configReloadFailed, + object: nil, + userInfo: [ + "message": errorMessage, + "response": tcpResult.response ?? "", + ] + ) + } return ReloadResult( success: false, response: tcpResult.response, - errorMessage: tcpResult.errorMessage ?? "TCP reload failed", + errorMessage: errorMessage, protocol: .tcp ) } } + /// True if the error came from the 3s reload-cooldown throttle rather + /// than a real failure. + private func isCooldownBlockMessage(_ message: String) -> Bool { + message.contains("Reload blocked") && message.contains("cooldown") + } + + /// When a reload is blocked by the cooldown, we still want it to actually + /// happen so the config file we just wrote reaches kanata. Schedule a + /// deferred retry once the cooldown expires; de-duped via a single + /// outstanding task so rapid edits coalesce into one final reload. + private var deferredReloadTask: Task? + + private func scheduleDeferredReloadAfterCooldown() { + deferredReloadTask?.cancel() + deferredReloadTask = Task { [weak self] in + // 3s cooldown + a little slop so the safety check passes. + try? await Task.sleep(nanoseconds: 3_200_000_000) + guard let self, !Task.isCancelled else { return } + AppLogger.shared.log("πŸ” [Reload] Firing deferred reload after cooldown expiry") + await self.triggerReload() + self.deferredReloadTask = nil + } + } + /// TCP-based config reload (no authentication required - see ADR-013) func triggerTCPReload() async -> TCPReloadResult { if TestEnvironment.isRunningTests { diff --git a/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift b/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift index 75c533135..c2df20b95 100644 --- a/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift +++ b/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift @@ -253,6 +253,27 @@ final class PreferencesService: @unchecked Sendable { } } + // MARK: - App-Scoped Suppression + + /// Bundle identifiers of apps where the live keyboard overlay AND the + /// context HUD hint panel should auto-hide while that app is frontmost. + /// Restored when focus moves to any other app. + /// + /// Ships with Figma by default β€” its hold-to-pan keyboard interactions + /// conflict with the overlay's hold-Space activation. + var overlaySuppressedBundleIDs: Set { + didSet { + UserDefaults.standard.set( + Array(overlaySuppressedBundleIDs).sorted(), + forKey: Keys.overlaySuppressedBundleIDs + ) + NotificationCenter.default.post( + name: .overlaySuppressedBundleIDsChanged, + object: nil + ) + } + } + // MARK: - Context HUD Configuration /// Display mode for the Context HUD (overlay only, HUD only, or both) @@ -359,6 +380,7 @@ final class PreferencesService: @unchecked Sendable { static let neovimReferenceTopicsVersion = "KeyPath.Neovim.ReferenceTopicsVersion" static let keyLabelStyle = "KeyPath.Display.KeyLabelStyle" static let overlayHiddenHintShowCount = "KeyPath.Education.OverlayHiddenHintShowCount" + static let overlaySuppressedBundleIDs = "KeyPath.Overlay.SuppressedBundleIDs" } // MARK: - Defaults @@ -384,6 +406,13 @@ final class PreferencesService: @unchecked Sendable { static let kindaVimLeaderHUDMode = KindaVimLeaderHUDMode.contextualCoach static let neovimReferenceTopics = NeovimTerminalCategory.defaultRawValues static let neovimReferenceTopicsVersion = 2 + // Figma uses hold-Space for pan/navigate; the overlay's hold-Space + // leader interferes. Ship disabled there by default β€” user can + // add more apps in Settings β†’ Experimental. + static let overlaySuppressedBundleIDs: Set = [ + "com.figma.Desktop", + "com.figma.agent" + ] } // MARK: - Initialization @@ -439,6 +468,14 @@ final class PreferencesService: @unchecked Sendable { overlayHiddenHintShowCount = UserDefaults.standard.object(forKey: Keys.overlayHiddenHintShowCount) as? Int ?? 0 + // App-scoped overlay suppression list. UserDefaults stores an Array; + // decode to Set, fall back to the Figma-seeded default if unset. + if let stored = UserDefaults.standard.stringArray(forKey: Keys.overlaySuppressedBundleIDs) { + overlaySuppressedBundleIDs = Set(stored) + } else { + overlaySuppressedBundleIDs = Defaults.overlaySuppressedBundleIDs + } + // Context HUD preferences let hudModeString = UserDefaults.standard.string(forKey: Keys.contextHUDDisplayMode) ?? Defaults.contextHUDDisplayMode.rawValue diff --git a/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift b/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift index 92b4bc338..f689e90a5 100644 --- a/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift +++ b/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift @@ -250,19 +250,17 @@ public enum PackRegistry { // MARK: - Pack 8: Mission Control - /// Collection-backed pack over `missionControl`. Three-modifier chords - /// (lctl + lmet + lalt + {up,down,left,right,d,n}) fire the system's - /// Mission Control, ExposΓ©, Desktop, and Notification Center actions β€” - /// without fighting the physical F3 or binding a new shortcut from - /// scratch. Renders via the generic collection-mappings fallback in - /// Pack Detail. + /// Collection-backed pack over `missionControl`. Leader (Space) held, + /// then a single letter fires the system's Mission Control, ExposΓ©, + /// Desktop, and Notification Center actions. One-hand, no reach β€” + /// consistent with the rest of the Gallery's Leader-based packs. public static let missionControl = Pack( id: "com.keypath.pack.mission-control", version: "1.0.0", name: "Mission Control", - tagline: "Shortcuts for ExposΓ©, Desktop, Notification Center", + tagline: "Leader + single key for ExposΓ©, Desktops, Notifications", shortDescription: - "Triple-chord shortcuts (Ctrl + Cmd + Option + a direction) for Mission Control, App ExposΓ©, Desktop switching, Show Desktop, and Notification Center. Avoids the F3 muscle memory, no reach.", + "Hold Space, then: M = Mission Control, E = App ExposΓ©, B = Show Desktop, C = Notification Center, [ / ] for previous / next Desktop. One-hand shortcuts, no reach for F3.", longDescription: "", category: "Productivity", iconSymbol: "rectangle.3.group", diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift index fd6cf763d..c8868c692 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift @@ -325,24 +325,38 @@ struct RuleCollectionCatalog { RuleCollection( id: RuleCollectionIdentifier.missionControl, name: "Mission Control", - summary: "Quick access to Mission Control, App ExposΓ©, and Desktop switching.", + summary: "Leader β†’ single key: Mission Control, ExposΓ©, Desktops, Notification Center.", category: .navigation, - // Inputs are expressed as kanata chords (space-separated physical - // keys). The generator emits these into defchordsv2; a modifier- - // prefix form like "C-M-A-up" would end up in defsrc, which only - // accepts single physical keys and fails validation. + // Maps onto uncommon keys inside the navigation layer so users + // can hit Mission Control actions as Leader (Space) + single + // letter β€” much cheaper than the previous 3-modifier chord form + // and consistent with the rest of the Gallery (Vim Nav, Numpad, + // Window Snapping all share the Leader β†’ key pattern). + // + // Keys chosen to avoid colliding with Vim Navigation's nav-layer + // bindings (h/j/k/l, y/p, g, u, d, n, o, a, r, /, 4, 0, x, .). mappings: [ - KeyMapping(input: "lctl lmet lalt up", output: "C-up", description: "Mission Control"), - KeyMapping(input: "lctl lmet lalt down", output: "C-down", description: "App ExposΓ©"), - KeyMapping(input: "lctl lmet lalt left", output: "C-left", description: "Previous Desktop"), - KeyMapping(input: "lctl lmet lalt right", output: "C-right", description: "Next Desktop"), - KeyMapping(input: "lctl lmet lalt d", output: "f11", description: "Show Desktop"), - KeyMapping(input: "lctl lmet lalt n", output: "C-S-n", description: "Notification Center") + KeyMapping(input: "m", output: "C-up", description: "Mission Control"), + KeyMapping(input: "e", output: "C-down", description: "App ExposΓ©"), + KeyMapping(input: "[", output: "C-left", description: "Previous Desktop"), + KeyMapping(input: "]", output: "C-right", description: "Next Desktop"), + KeyMapping(input: "b", output: "f11", description: "Show Desktop"), + KeyMapping(input: "c", output: "C-S-n", description: "Notification Center") ], isEnabled: false, isSystemDefault: false, icon: "rectangle.3.group", tags: ["mission control", "spaces", "desktop"], + targetLayer: .navigation, + // Piggybacks on the nav layer β€” the user needs a collection that + // activates nav (Vim Navigation or Leader Key). Providing our + // own Space activator keeps the pack standalone-usable; the + // generator's dedupe coalesces duplicate activators. + momentaryActivator: MomentaryActivator( + input: "space", + targetLayer: .navigation + ), + activationHint: "Leader β†’ single key", configuration: .table ) } diff --git a/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift b/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift index f85fae599..2a7fcbac6 100644 --- a/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift +++ b/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift @@ -365,6 +365,15 @@ final class ContextHUDController { // MARK: - Show / Dismiss private func showForLayer(_ layerName: String) { + // Respect the user's per-app suppression list β€” if the frontmost + // app is listed (e.g. Figma), skip showing the HUD entirely. Also + // dismiss if it's already up, for apps the user just activated. + if let frontBundle = NSWorkspace.shared.frontmostApplication?.bundleIdentifier, + PreferencesService.shared.overlaySuppressedBundleIDs.contains(frontBundle) + { + dismiss() + return + } // Cancel any pending dismiss dismissTask?.cancel() dismissTask = nil @@ -724,7 +733,11 @@ final class ContextHUDController { private func positionWindow() { guard let window, let screen = NSScreen.main else { return } - // Size to fit content + let screenFrame = screen.visibleFrame + + // Size to fit content. Cap at the available screen width (with a + // margin) so multi-group HUDs with many entries still fit on-screen + // without clipping, instead of hitting a hardcoded 1100pt ceiling. if let hostingView { // Force SwiftUI layout pass so fittingSize reflects current content, // not stale layout from a previous show/update cycle. @@ -732,13 +745,16 @@ final class ContextHUDController { hostingView.layoutSubtreeIfNeeded() let fittingSize = hostingView.fittingSize - let width = min(max(fittingSize.width, 240), 1100) - let height = min(max(fittingSize.height, 100), 600) + let horizontalMargin: CGFloat = 64 + let verticalMargin: CGFloat = 80 + let maxWidth = max(600, screenFrame.width - horizontalMargin) + let maxHeight = max(400, screenFrame.height - verticalMargin) + let width = min(max(fittingSize.width, 240), maxWidth) + let height = min(max(fittingSize.height, 100), maxHeight) window.setContentSize(NSSize(width: width, height: height)) } // Position at center of screen - let screenFrame = screen.visibleFrame let windowFrame = window.frame let x = screenFrame.midX - (windowFrame.width / 2) let y = screenFrame.midY - (windowFrame.height / 2) diff --git a/Sources/KeyPathAppKit/UI/Gallery/GalleryWindowController.swift b/Sources/KeyPathAppKit/UI/Gallery/GalleryWindowController.swift index f38299394..dc474c813 100644 --- a/Sources/KeyPathAppKit/UI/Gallery/GalleryWindowController.swift +++ b/Sources/KeyPathAppKit/UI/Gallery/GalleryWindowController.swift @@ -5,12 +5,16 @@ import AppKit import SwiftUI @MainActor -final class GalleryWindowController { +final class GalleryWindowController: NSObject { static let shared = GalleryWindowController() private var window: NSWindow? + private var willCloseObserver: NSObjectProtocol? - /// Open (or focus if already open) the Gallery window. + /// Open (or focus if already open) the Gallery window. Hides the live + /// keyboard overlay while the Gallery is up (same pattern Settings uses + /// via `autoHideOnceForSettings`) and restores it on close, so the + /// overlay doesn't hover on top of the Gallery sheet. /// - Parameter kanataManager: the env object the content view reads. func showWindow(kanataManager: KanataViewModel) { if let existingWindow = window, existingWindow.isVisible { @@ -36,12 +40,34 @@ final class GalleryWindowController { if !newWindow.setFrameUsingName("KeyPathGalleryWindow") { newWindow.center() } - newWindow.makeKeyAndOrderFront(nil) + // Hide the overlay so it doesn't float on top of the Gallery window. + // Reuses the same API Settings uses β€” the controller remembers + // pre-hide visibility and restores it when we reset the guard. + LiveKeyboardOverlayController.shared.autoHideOnceForSettings() + + // Observe this window's willClose so we can restore the overlay + // when the user dismisses the Gallery (either clicking the βœ• or + // Cmd+W). Scoped to this window so we don't fire on unrelated closes. + willCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: newWindow, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + LiveKeyboardOverlayController.shared.resetSettingsAutoHideGuard() + self?.willCloseObserver.map(NotificationCenter.default.removeObserver) + self?.willCloseObserver = nil + self?.window = nil + } + } + + newWindow.makeKeyAndOrderFront(nil) self.window = newWindow } /// Close the Gallery window if it's open. Does nothing otherwise. + /// Overlay restoration happens via the willClose observer. func closeWindow() { window?.close() } diff --git a/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift b/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift index b897c0c55..6b00074d7 100644 --- a/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift +++ b/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift @@ -45,6 +45,16 @@ struct PackDetailView: View { /// to a persisted collection config is follow-up work. @State private var homeRowModsConfig: HomeRowModsConfig = .init() + /// Local state for the embedded Auto Shift Symbols editor. + @State private var autoShiftConfig: AutoShiftSymbolsConfig = .init() + + /// Currently-selected layer-preset id (for the Symbol/Fun packs). + @State private var selectedLayerPresetId: String? + + /// Help sheet presentation (Home Row Mods only β€” mirrors the Rules tab's + /// `?` button that opens the HRM markdown help). + @State private var showingHomeRowModsHelp = false + var body: some View { VStack(alignment: .leading, spacing: 0) { header @@ -66,6 +76,9 @@ struct PackDetailView: View { loadDefaultQuickSettings() } .overlay(toastOverlay, alignment: .bottom) + .sheet(isPresented: $showingHomeRowModsHelp) { + MarkdownHelpSheet(resource: "home-row-mods", title: "Home Row Mods") + } } // MARK: - Header @@ -110,8 +123,28 @@ struct PackDetailView: View { HStack(alignment: .center, spacing: 16) { heroIcon VStack(alignment: .leading, spacing: 4) { - Text(pack.name) - .font(.system(size: 20, weight: .semibold)) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(pack.name) + .font(.system(size: 20, weight: .semibold)) + // Help button β€” mirrors the `?` on the Rules tab's + // Home Row Mods row. Only present for packs that + // have curated help markdown. + if helpResourceName != nil { + Button { + if helpResourceName != nil { + showingHomeRowModsHelp = true + } + } label: { + Image(systemName: "questionmark.circle") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .focusable(false) + .accessibilityLabel("\(pack.name) help") + .accessibilityIdentifier("pack-detail-help") + } + } Text(pack.tagline) .font(.system(size: 13)) .foregroundStyle(.secondary) @@ -346,6 +379,24 @@ struct PackDetailView: View { // MARK: - Binding list + /// Wraps embedded editors in `InsetBackPlane` with the same padding the + /// Rules tab uses, so Pack Detail editors visually match their Rules + /// counterparts (subtle inner-shadow container, consistent insets). + @ViewBuilder + private func embeddedEditor( + horizontalPadding: CGFloat = 16, + @ViewBuilder content: () -> Content + ) -> some View { + InsetBackPlane { + content() + .padding(.top, 8) + .padding(.bottom, 12) + .padding(.horizontal, horizontalPadding) + } + .opacity(isInstalled ? 1.0 : 0.55) + .animation(.easeInOut(duration: 0.2), value: isInstalled) + } + @ViewBuilder private var bindingsBlock: some View { if isHomeRowModsPack, let collectionID = pack.associatedCollectionID { @@ -354,8 +405,7 @@ struct PackDetailView: View { // so we embed the same view the Rules tab uses. All layer- // aware callbacks delegate into the same `kanataManager` // methods Rules uses so hold-to-layer bindings work here too. - VStack(alignment: .leading, spacing: 0) { - Divider() + embeddedEditor { HomeRowModsCollectionView( config: $homeRowModsConfig, availableLayers: availableHomeRowLayers(), @@ -371,22 +421,17 @@ struct PackDetailView: View { await kanataManager.batchEnableCollections(collectionIds) } ) - .opacity(isInstalled ? 1.0 : 0.55) - .animation(.easeInOut(duration: 0.2), value: isInstalled) } } else if let singleKeyCollection = associatedSingleKeyCollection { // Single-key remap with preset pills (Escape Remap, Delete // Enhancement, Backup Caps Lock). Reuses Rules' view directly. - VStack(alignment: .leading, spacing: 0) { - Divider() + embeddedEditor { SingleKeyPickerContent( collection: singleKeyCollection, onSelectOutput: { output in Task { await applySingleKeyEdit(output: output) } } ) - .opacity(isInstalled ? 1.0 : 0.55) - .animation(.easeInOut(duration: 0.2), value: isInstalled) } } else if let pickerConfig = associatedPickerConfig { // Phase 2: for packs that map onto an existing RuleCollection's @@ -398,8 +443,7 @@ struct PackDetailView: View { // pack, the user's click registers in the picker's internal // selection state, and the dim lifts. Phase 3 will persist the // selected preset through to the installed CustomRule. - VStack(alignment: .leading, spacing: 0) { - Divider() + embeddedEditor { TapHoldPickerContent( config: pickerConfig, isEditable: true, @@ -415,19 +459,71 @@ struct PackDetailView: View { // Picker seeds its selection state at init. Re-id whenever // the live selection flips (e.g. on appear, after refresh // from the live rule) so SwiftUI re-creates it with the - // fresh seed. User clicks update our local state, which - // bumps the id, which re-seeds β€” harmless because the new - // seed matches what the user just clicked. + // fresh seed. .id("\(pickerTapSelection ?? "")-\(pickerHoldSelection ?? "")") - .opacity(isInstalled ? 1.0 : 0.55) - .animation(.easeInOut(duration: 0.2), value: isInstalled) + } + } else if let autoShiftCollection = associatedAutoShiftCollection { + // Auto Shift Symbols: enabled-keys toggles + timing slider + fast- + // typing protection. Same view the Rules tab uses. + embeddedEditor { + AutoShiftCollectionView( + config: autoShiftConfig, + onConfigChanged: { newConfig in + Task { await applyAutoShiftEdit(newConfig, collectionID: autoShiftCollection.id) } + } + ) + .id(autoShiftCollection.id) + } + } else if let layerPresetCollection = associatedLayerPresetCollection { + // Layer preset picker β€” Symbol/Fun layers. Users choose a preset + // (e.g. Mirrored, Alphabetical) that redefines the layer's + // mappings. Reuses the Rules-tab picker directly. + embeddedEditor { + LayerPresetPickerContent( + collection: layerPresetCollection, + onSelectPreset: { presetId in + selectedLayerPresetId = presetId + Task { await applyLayerPresetEdit(presetId: presetId, collectionID: layerPresetCollection.id) } + } + ) + .id(selectedLayerPresetId ?? "") + } + } else if let collection = liveAssociatedCollection, + collection.id == RuleCollectionIdentifier.windowSnapping { + // Window Snapping has a custom visual editor in Rules β€” convention + // picker + monitor canvas + floating action cards. Pack Detail + // embeds the same view so the experience is identical. + // Rules uses horizontal padding of 12 for this particular editor. + embeddedEditor(horizontalPadding: 12) { + WindowSnappingView( + mappings: collection.mappings, + convention: collection.windowKeyConvention ?? .standard, + onConventionChange: { convention in + Task { await applyWindowConventionEdit(convention, collectionID: collection.id) } + } + ) } } else if pack.bindings.isEmpty, let collection = liveAssociatedCollection, !collection.mappings.isEmpty { - // Collection-backed pack with no explicit bindings β€” e.g. Vim - // Navigation. Render the collection's mapping table so there's - // a single source of truth if the collection changes upstream. - collectionMappingsBlock(collection) + // Collection-backed pack with no explicit bindings and a plain + // `.table` config (Vim Navigation, Mission Control, Numpad). + // Uses the same MappingTableContent view Rules uses so the + // table styling matches exactly. + embeddedEditor { + VStack(alignment: .leading, spacing: 8) { + if let hint = collection.activationHint, !hint.isEmpty { + Text(hint) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + MappingTableContent( + mappings: collection.mappings.map { + ($0.input, $0.output, $0.shiftedOutput, $0.ctrlOutput, + $0.description, $0.sectionBreak, isInstalled, $0.id, nil) + } + ) + } + } } else { VStack(alignment: .leading, spacing: 8) { Divider() @@ -441,6 +537,16 @@ struct PackDetailView: View { } } + /// Help markdown resource name for packs that have curated help content. + /// Returns nil for packs without β€” matches the Rules tab, where only + /// Home Row Mods surfaces a `?` button today. + private var helpResourceName: String? { + switch pack.associatedCollectionID { + case RuleCollectionIdentifier.homeRowMods: "home-row-mods" + default: nil + } + } + /// Live `RuleCollection` this pack is associated with. Used to hand /// to Rules' view components (they take the whole collection so they /// can render the user's latest selections, not catalog defaults). @@ -463,6 +569,24 @@ struct PackDetailView: View { return collection } + /// Collection backing an Auto Shift-style pack (enabled-keys toggles + + /// timing slider). Matches the `.autoShiftSymbols` configuration case. + private var associatedAutoShiftCollection: RuleCollection? { + guard let collection = liveAssociatedCollection, + case .autoShiftSymbols = collection.configuration + else { return nil } + return collection + } + + /// Collection backing a layer-preset pack (Symbol, Fun). Matches the + /// `.layerPresetPicker` configuration case. + private var associatedLayerPresetCollection: RuleCollection? { + guard let collection = liveAssociatedCollection, + case .layerPresetPicker = collection.configuration + else { return nil } + return collection + } + /// True if this pack's bindings line up with the Home Row Mods /// collection's input set β€” in which case we render that collection's /// interactive keyboard + modifier controls instead of the generic @@ -531,46 +655,6 @@ struct PackDetailView: View { .padding(.vertical, 2) } - /// Read-only mapping table for collection-backed packs whose behavior is - /// a fixed table rather than a user-tuned picker (e.g. Vim Navigation). - /// Sourced directly from the collection so there's no risk of drift. - @ViewBuilder - private func collectionMappingsBlock(_ collection: RuleCollection) -> some View { - VStack(alignment: .leading, spacing: 8) { - Divider() - if let hint = collection.activationHint, !hint.isEmpty { - Text(hint) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.bottom, 2) - } - Text("What this will change") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - // Compact two-column layout β€” keycap on the left, description on - // the right. Keeps Pack Detail scannable even when the collection - // has ~15-20 mappings. - LazyVGrid( - columns: [ - GridItem(.fixed(72), alignment: .leading), - GridItem(.flexible(), alignment: .leading) - ], - alignment: .leading, - spacing: 4 - ) { - ForEach(collection.mappings) { mapping in - KeyCapChip(text: RuleBehaviorSummaryView.formatKeyForBehavior(mapping.input)) - Text(mapping.description ?? mapping.output) - .font(.system(size: 12)) - .foregroundStyle(.primary) - .lineLimit(2) - } - } - .opacity(isInstalled ? 1.0 : 0.6) - .animation(.easeInOut(duration: 0.2), value: isInstalled) - } - } - // MARK: - Toast overlay @ViewBuilder @@ -687,9 +771,29 @@ struct PackDetailView: View { if let liveHomeRow { homeRowModsConfig = liveHomeRow } + if let liveAutoShift = liveAutoShiftConfig() { + autoShiftConfig = liveAutoShift + } + if let livePreset = liveLayerPresetId() { + selectedLayerPresetId = livePreset + } } } + private func liveAutoShiftConfig() -> AutoShiftSymbolsConfig? { + guard let collection = associatedAutoShiftCollection, + case let .autoShiftSymbols(cfg) = collection.configuration + else { return nil } + return cfg + } + + private func liveLayerPresetId() -> String? { + guard let collection = associatedLayerPresetCollection, + case let .layerPresetPicker(cfg) = collection.configuration + else { return nil } + return cfg.selectedPresetId + } + private func liveSingleKeySelection() async -> String? { guard let collectionID = pack.associatedCollectionID else { return nil } let collections = await kanataManager.underlyingManager @@ -720,6 +824,38 @@ struct PackDetailView: View { ) } + /// Mirror of `applyHomeRowEdit` for Auto Shift Symbols. + private func applyAutoShiftEdit(_ newConfig: AutoShiftSymbolsConfig, collectionID: UUID) async { + autoShiftConfig = newConfig + if !isInstalled { + await install(skipFinalReload: true) + } + await kanataManager.updateAutoShiftSymbolsConfig( + collectionId: collectionID, + config: newConfig + ) + } + + /// Mirror of `applyHomeRowEdit` for Window Snapping's convention picker + /// (Standard L/R/U/I/J/K vs Vim H/L/Y/U/B/N). + private func applyWindowConventionEdit(_ convention: WindowKeyConvention, collectionID: UUID) async { + if !isInstalled { + await install(skipFinalReload: true) + } + await kanataManager.updateWindowKeyConvention(collectionID, convention: convention) + } + + /// Mirror of `applyHomeRowEdit` for layer-preset packs (Symbol, Fun). + /// Installs the pack on first touch, then switches the collection's + /// selected preset so the generated kanata config rebinds the layer. + private func applyLayerPresetEdit(presetId: String, collectionID: UUID) async { + selectedLayerPresetId = presetId + if !isInstalled { + await install(skipFinalReload: true) + } + await kanataManager.updateCollectionLayerPreset(collectionID, presetId: presetId) + } + /// Supplies the same layer list Rules uses when rendering the Home Row /// Mods editor, so hold-to-layer bindings resolve correctly inside /// Pack Detail. diff --git a/Sources/KeyPathAppKit/UI/LayerIndicatorWindow.swift b/Sources/KeyPathAppKit/UI/LayerIndicatorWindow.swift index c2498dc83..fe9a7cc79 100644 --- a/Sources/KeyPathAppKit/UI/LayerIndicatorWindow.swift +++ b/Sources/KeyPathAppKit/UI/LayerIndicatorWindow.swift @@ -109,6 +109,19 @@ class LayerIndicatorManager { func showLayer(_ layerName: String) { AppLogger.shared.log("πŸͺŸ [LayerIndicator] showLayer called with: '\(layerName)' (previous: '\(previousLayer)')") + // Respect the per-app suppression list β€” if the user is in Figma + // (etc), skip the indicator AND the directional sounds. We still + // update `previousLayer` so the next legitimate change doesn't + // double-fire. + if let frontBundle = NSWorkspace.shared.frontmostApplication?.bundleIdentifier, + PreferencesService.shared.overlaySuppressedBundleIDs.contains(frontBundle) + { + AppLogger.shared.debug("πŸͺŸ [LayerIndicator] Suppressed for app \(frontBundle)") + previousLayer = layerName + window?.orderOut(nil) + return + } + let isBase = layerName.lowercased() == "base" let wasBase = previousLayer.lowercased() == "base" diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift index 092039391..9be491e39 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift @@ -865,6 +865,37 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { } } + // MARK: - App-Scoped Suppression + + /// Per-app suppression state. Independent of the Settings/Wizard guard + /// above so they don't clobber each other β€” a user could open Settings + /// while Figma is frontmost and both reasons should compose. + private var wasVisibleBeforeAppSuppression: Bool = false + private var isAppSuppressed: Bool = false + + /// Hide the overlay because the frontmost app is on the user's + /// suppression list (e.g. Figma, where hold-Space conflicts). Stashes + /// pre-hide visibility so `restoreFromAppSuppression` can put it back. + func suppressForApp() { + guard !isAppSuppressed else { return } + isAppSuppressed = true + wasVisibleBeforeAppSuppression = isVisible + if isVisible { + isVisible = false + } + } + + /// Restore the overlay when leaving a suppressed app. + func restoreFromAppSuppression() { + guard isAppSuppressed else { return } + let shouldRestore = wasVisibleBeforeAppSuppression + isAppSuppressed = false + wasVisibleBeforeAppSuppression = false + if shouldRestore, !isVisible { + isVisible = true + } + } + // MARK: - Window Management private func showWindow() { diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayAppSuppressor.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayAppSuppressor.swift new file mode 100644 index 000000000..53f240865 --- /dev/null +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayAppSuppressor.swift @@ -0,0 +1,65 @@ +// Watches NSWorkspace for frontmost-app changes and suppresses/restores +// the live keyboard overlay based on the user's +// `PreferencesService.overlaySuppressedBundleIDs` list. +// +// The ContextHUD already checks the same list inline when deciding whether +// to show, so it doesn't need a parallel suppressor β€” this type handles +// the overlay's longer-lived visibility state. + +import AppKit +import Foundation +import KeyPathCore + +@MainActor +final class OverlayAppSuppressor { + static let shared = OverlayAppSuppressor() + + private var activationObserver: NSObjectProtocol? + private var preferenceChangeObserver: NSObjectProtocol? + + func start() { + guard activationObserver == nil else { return } + + // Apply state for whatever app is frontmost right now. + applyForCurrentApp() + + activationObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.applyForCurrentApp() + } + } + + // When the user edits the list in Settings, re-evaluate so the + // overlay doesn't stay suppressed in an app the user just removed. + preferenceChangeObserver = NotificationCenter.default.addObserver( + forName: .overlaySuppressedBundleIDsChanged, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.applyForCurrentApp() + } + } + } + + func stop() { + activationObserver.map(NSWorkspace.shared.notificationCenter.removeObserver) + activationObserver = nil + preferenceChangeObserver.map(NotificationCenter.default.removeObserver) + preferenceChangeObserver = nil + } + + private func applyForCurrentApp() { + let frontBundle = NSWorkspace.shared.frontmostApplication?.bundleIdentifier + let suppressed = PreferencesService.shared.overlaySuppressedBundleIDs + if let frontBundle, suppressed.contains(frontBundle) { + LiveKeyboardOverlayController.shared.suppressForApp() + } else { + LiveKeyboardOverlayController.shared.restoreFromAppSuppression() + } + } +} diff --git a/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView+MappingTable.swift b/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView+MappingTable.swift index 1bacdbd02..d0518311f 100644 --- a/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView+MappingTable.swift +++ b/Sources/KeyPathAppKit/UI/Rules/RulesSummaryView+MappingTable.swift @@ -199,6 +199,23 @@ struct MappingTableContent: View { return macName } + // Space-separated chord input (kanata's defchordsv2 form) β€” + // e.g. "Lctl Lmet Lalt Up" β†’ "βŒƒβŒ˜βŒ₯↑". Each token is formatted via + // the macModifiers map or arrow symbols; we join with no separator + // so the visual mirrors the Action column. + if key.contains(" ") { + let tokens = key.split(separator: " ") + let formatted = tokens.map { token -> String in + let t = String(token) + if let macName = macModifiers[t] { + // Take just the leading symbol (e.g. "⌘" from "⌘ Cmd"). + return macName.components(separatedBy: " ").first ?? macName + } + return formatModifierPrefixNotation(t, macModifiers: macModifiers) + } + return formatted.joined() + } + // Handle modifier prefix notation (e.g., "C-M-A-up" -> "βŒƒβŒ˜βŒ₯↑") return formatModifierPrefixNotation(key, macModifiers: macModifiers) } diff --git a/Sources/KeyPathAppKit/UI/Settings/ExperimentalSettingsSection.swift b/Sources/KeyPathAppKit/UI/Settings/ExperimentalSettingsSection.swift index 7bf05030b..0c813a4e1 100644 --- a/Sources/KeyPathAppKit/UI/Settings/ExperimentalSettingsSection.swift +++ b/Sources/KeyPathAppKit/UI/Settings/ExperimentalSettingsSection.swift @@ -17,6 +17,7 @@ struct ExperimentalSettingsSection: View { ? UserDefaults.standard.bool(forKey: LayoutPreferences.qmkSearchEnabledKey) : LayoutPreferences.qmkSearchEnabledDefault @State private var accessibilityTestMode = PreferencesService.shared.accessibilityTestMode + @State private var suppressedBundleIDs: [String] = Array(PreferencesService.shared.overlaySuppressedBundleIDs).sorted() var body: some View { ScrollView { @@ -60,6 +61,69 @@ struct ExperimentalSettingsSection: View { } } + // Per-app overlay suppression + SettingsCard { + VStack(alignment: .leading, spacing: 12) { + SectionHeader( + icon: "eye.slash", + title: "Hide Overlay in Specific Apps", + color: .indigo + ) + Text("The live keyboard overlay and hint panel auto-hide while these apps are frontmost. They restore when you switch away.") + .font(.caption) + .foregroundStyle(.secondary) + + VStack(spacing: 6) { + ForEach(suppressedBundleIDs, id: \.self) { bundleID in + HStack { + Image(systemName: appIcon(for: bundleID)) + .foregroundStyle(.secondary) + .frame(width: 16) + VStack(alignment: .leading, spacing: 1) { + Text(appDisplayName(for: bundleID)) + .font(.system(size: 12, weight: .medium)) + Text(bundleID) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + } + Spacer() + Button { + removeBundleID(bundleID) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.red.opacity(0.7)) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(appDisplayName(for: bundleID))") + .accessibilityIdentifier("overlay-suppressed-remove-\(bundleID)") + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.secondary.opacity(0.08)) + ) + } + if suppressedBundleIDs.isEmpty { + Text("No apps configured.") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.vertical, 8) + } + } + + Button { + addAppViaPicker() + } label: { + Label("Add App…", systemImage: "plus") + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("overlay-suppressed-add-app") + } + } + // Testing Section SettingsCard { VStack(alignment: .leading, spacing: 12) { @@ -275,6 +339,54 @@ struct ExperimentalSettingsSection: View { } .padding(.vertical, 4) } + + // MARK: - Per-app suppression helpers + + private func addAppViaPicker() { + let panel = NSOpenPanel() + panel.title = "Choose an app" + panel.prompt = "Choose" + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + guard panel.runModal() == .OK, let url = panel.url else { return } + guard let bundle = Bundle(url: url), + let bundleID = bundle.bundleIdentifier + else { return } + var updated = Set(suppressedBundleIDs) + updated.insert(bundleID) + applySuppressedChange(updated) + } + + private func removeBundleID(_ id: String) { + var updated = Set(suppressedBundleIDs) + updated.remove(id) + applySuppressedChange(updated) + } + + private func applySuppressedChange(_ updated: Set) { + suppressedBundleIDs = Array(updated).sorted() + services.preferences.overlaySuppressedBundleIDs = updated + } + + /// Best-effort nice display name for a bundle id (reads + /// `CFBundleDisplayName` / `CFBundleName` from the installed app). + /// Falls back to the last path component of the bundle id. + private func appDisplayName(for bundleID: String) -> String { + if let path = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID)?.path, + let bundle = Bundle(path: path), + let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) + { + return name + } + return bundleID.components(separatedBy: ".").last ?? bundleID + } + + private func appIcon(for _: String) -> String { + "app" + } } // MARK: - Supporting Views diff --git a/Sources/KeyPathAppKit/Utilities/Notifications.swift b/Sources/KeyPathAppKit/Utilities/Notifications.swift index 8290b4ad0..4b0350659 100644 --- a/Sources/KeyPathAppKit/Utilities/Notifications.swift +++ b/Sources/KeyPathAppKit/Utilities/Notifications.swift @@ -48,6 +48,10 @@ extension Notification.Name { static let configReloadFailed = Notification.Name("KeyPath.Config.ReloadFailed") static let configReloadRecovered = Notification.Name("KeyPath.Config.ReloadRecovered") + /// Overlay app-scoped suppression list changed β€” observers should + /// re-evaluate whether to hide/show the overlay for the current app. + static let overlaySuppressedBundleIDsChanged = Notification.Name("KeyPath.Overlay.SuppressedBundleIDsChanged") + /// Mapper drawer /// Posted when a key is clicked and should be selected in the mapper drawer /// userInfo: keyCode (UInt16), inputKey (String), outputKey (String), optional shiftedOutputKey (String), layer (String) diff --git a/Sources/KeyPathAppKit/Utilities/SoundManager.swift b/Sources/KeyPathAppKit/Utilities/SoundManager.swift index 6f1f06acf..0e5085586 100644 --- a/Sources/KeyPathAppKit/Utilities/SoundManager.swift +++ b/Sources/KeyPathAppKit/Utilities/SoundManager.swift @@ -9,10 +9,24 @@ class SoundManager { private init() {} + /// Gate that silences every sound when: + /// * We're running inside XCTest (pre-existing behavior), OR + /// * The frontmost app's bundle identifier is on the user's + /// `overlaySuppressedBundleIDs` list (Settings β†’ Experimental). + private func shouldSuppress() -> Bool { + if TestEnvironment.isRunningTests { return true } + if let front = NSWorkspace.shared.frontmostApplication?.bundleIdentifier, + PreferencesService.shared.overlaySuppressedBundleIDs.contains(front) + { + return true + } + return false + } + /// Play tink sound when saving configuration func playTinkSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("πŸ§ͺ [Sound] Suppressed tink sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("πŸ”‡ [Sound] Suppressed tink sound") return } NSSound(named: "Tink")?.play() @@ -21,8 +35,8 @@ class SoundManager { /// Play glass sound when configuration reload is complete func playGlassSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("πŸ§ͺ [Sound] Suppressed glass sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("πŸ”‡ [Sound] Suppressed glass sound") return } NSSound(named: "Glass")?.play() @@ -31,8 +45,8 @@ class SoundManager { /// Play system beep for errors func playErrorSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("πŸ§ͺ [Sound] Suppressed error beep in test environment") + if shouldSuppress() { + AppLogger.shared.log("πŸ”‡ [Sound] Suppressed error beep") return } NSSound.beep() @@ -41,8 +55,8 @@ class SoundManager { /// Play warning sound for conflicts (non-blocking warnings) func playWarningSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("πŸ§ͺ [Sound] Suppressed warning sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("πŸ”‡ [Sound] Suppressed warning sound") return } // "Basso" is a low, cautionary sound - appropriate for warnings @@ -52,8 +66,8 @@ class SoundManager { /// Play submarine sound for successful operations (alternative to glass) func playSubmarineSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("πŸ§ͺ [Sound] Suppressed submarine sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("πŸ”‡ [Sound] Suppressed submarine sound") return } NSSound(named: "Submarine")?.play() @@ -64,8 +78,8 @@ class SoundManager { /// Play subtle sound when entering a non-base layer (higher pitch) func playLayerUpSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("πŸ§ͺ [Sound] Suppressed layer-up sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("πŸ”‡ [Sound] Suppressed layer-up sound") return } // "Tink" is a light, higher-pitched tap - good for going "up" @@ -75,8 +89,8 @@ class SoundManager { /// Play subtle sound when returning to base layer (lower pitch) func playLayerDownSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("πŸ§ͺ [Sound] Suppressed layer-down sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("πŸ”‡ [Sound] Suppressed layer-down sound") return } // "Pop" has a lower, softer quality - good for settling "down" @@ -88,8 +102,8 @@ class SoundManager { /// Play subtle sound when overlay appears func playOverlayShowSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("πŸ§ͺ [Sound] Suppressed overlay-show sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("πŸ”‡ [Sound] Suppressed overlay-show sound") return } // "Bottle" is a soft cork/bubble sound - gentle for appearing @@ -99,8 +113,8 @@ class SoundManager { /// Play subtle sound when overlay hides func playOverlayHideSound() { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("πŸ§ͺ [Sound] Suppressed overlay-hide sound in test environment") + if shouldSuppress() { + AppLogger.shared.log("πŸ”‡ [Sound] Suppressed overlay-hide sound") return } // "Funk" is a subtle descending tone - good for dismissing diff --git a/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift b/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift index 1d61a56dc..0ce110150 100644 --- a/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift +++ b/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift @@ -93,25 +93,20 @@ final class PackRuntimeBehaviorTests: XCTestCase { "Holding space + w should put us in the 'window' layer for window-snapping actions") } - /// Mission Control maps 3-modifier chords (lctl + lmet + lalt + direction - /// or letter) to plain key combos macOS already interprets β€” e.g. the - /// `lctl + lmet + lalt + up` chord emits `C-up`, which is macOS's own - /// Mission Control shortcut. No AX or push-msg indirection, so the - /// simulator observes the actual output. - func testMissionControlChordEmitsCtrlUp() throws { + /// Mission Control: Leader (Space) + single key. Holding Space for the + /// nav layer, then tapping `m` should emit `C-up` (macOS's Mission + /// Control shortcut β†’ we see `up` in the simulator output as part of + /// lctl+up). Replaced the earlier 3-modifier-chord design which was + /// harder to press than the F3 it claimed to improve on. + func testMissionControlLeaderMEmitsCtrlUp() throws { let events = try simulate( collectionIDs: [RuleCollectionIdentifier.missionControl], - // Press all 3 modifiers together with up inside the chord - // window, release in reverse. Timings stay under defchordsv2's - // chord window. - script: "↓lctl πŸ•5 ↓lmet πŸ•5 ↓lalt πŸ•5 ↓up πŸ•50 ↑up πŸ•20 ↑lalt πŸ•5 ↑lmet πŸ•5 ↑lctl πŸ•50" + // Hold Space (β†’ nav layer), tap `m`, release. + script: "↓spc πŸ•300 ↓m πŸ•30 ↑m πŸ•30 ↑spc πŸ•30" ) let output = outputKeys(events) - // The chord resolves to `C-up` which emits lctl+up. If the chord - // hadn't fired, we'd still see lctl but not a clean `up` output - // (up would still be suppressed by defsrc's chord input claim). XCTAssertTrue(output.contains("up"), - "Mission Control chord should emit up (as part of C-up); got: \(output)") + "Leader + m should emit C-up (lctl+up) for Mission Control; got: \(output)") } /// Auto Shift Symbols makes symbol keys dual-role: quick tap emits the diff --git a/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift b/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift index 88a0dcd58..5384887ac 100644 --- a/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift +++ b/Tests/KeyPathTests/Infrastructure/KanataConfigurationGeneratorSnapshotTests.swift @@ -96,7 +96,7 @@ final class KanataConfigurationGeneratorSnapshotTests: XCTestCase { assertContains(config, "(multi (release-layer window) @act_window_h (push-msg \"layer:base\"))") } - func testModifierChordsPreservedInsideMultiWrapper() throws { + func testModifierChordsExpandedInsideMultiWrapper() throws { let navCollection = try makeCollection( id: XCTUnwrap(UUID(uuidString: "11111111-1111-1111-1111-111111111111")), name: "Navigation", @@ -115,18 +115,19 @@ final class KanataConfigurationGeneratorSnapshotTests: XCTestCase { navActivationMode: .tapToToggle ) - // Modifier chords should pass through intact β€” not expanded to "lmet v" / "lctl lsft z" - assertContains(config, "(multi (release-layer nav) M-v (push-msg \"layer:base\"))") - assertContains(config, "(multi (release-layer nav) C-S-z (push-msg \"layer:base\"))") + // Modifier chords must expand inside the release-layer multi wrapper. + // Kanata drops modifier-prefixed tokens after release-layer, so the + // generator emits explicit nested multi forms instead. + assertContains(config, "(multi (release-layer nav) (multi lmet v) (push-msg \"layer:base\"))") + assertContains(config, "(multi (release-layer nav) (multi lctl lsft z) (push-msg \"layer:base\"))") - // Verify the expanded forms are NOT present XCTAssertFalse( - config.contains("lmet v"), - "M-v should not be expanded to 'lmet v' inside multi\n\nActual output:\n\(config)" + config.contains("(multi (release-layer nav) M-v (push-msg \"layer:base\"))"), + "M-v should be expanded inside multi\n\nActual output:\n\(config)" ) XCTAssertFalse( - config.contains("lctl lsft z"), - "C-S-z should not be expanded to 'lctl lsft z' inside multi\n\nActual output:\n\(config)" + config.contains("(multi (release-layer nav) C-S-z (push-msg \"layer:base\"))"), + "C-S-z should be expanded inside multi\n\nActual output:\n\(config)" ) } diff --git a/Tests/KeyPathTests/InstallationWizard/WizardSystemStatusOverviewPermissionVisibilityTests.swift b/Tests/KeyPathTests/InstallationWizard/WizardSystemStatusOverviewPermissionVisibilityTests.swift index b4ce9954c..dd3952834 100644 --- a/Tests/KeyPathTests/InstallationWizard/WizardSystemStatusOverviewPermissionVisibilityTests.swift +++ b/Tests/KeyPathTests/InstallationWizard/WizardSystemStatusOverviewPermissionVisibilityTests.swift @@ -5,7 +5,7 @@ import KeyPathWizardCore @MainActor final class WizardSystemStatusOverviewPermissionVisibilityTests: XCTestCase { - func testKanataPermissionWarningAppearsInInputMonitoringStatusRow() { + func testKanataPermissionWarningAppearsAsUnverifiedWithoutFullDiskAccess() { let warningIssue = WizardIssue( identifier: .permission(.kanataInputMonitoring), severity: .warning, @@ -32,7 +32,7 @@ final class WizardSystemStatusOverviewPermissionVisibilityTests: XCTestCase { let items = overview.statusItems let input = items.first(where: { $0.id == "input-monitoring" }) XCTAssertNotNil(input, "Expected an Input Monitoring status row") - XCTAssertEqual(input?.status, .warning, "Kanata permission warning should surface as warning status") + XCTAssertEqual(input?.status, .unverified, "Kanata permission warning should surface as unverified when Full Disk Access is unavailable") XCTAssertTrue( (input?.relatedIssues.contains { $0.identifier == .permission(.kanataInputMonitoring) } ?? false), "Input Monitoring row should include the underlying warning issue"