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
4 changes: 4 additions & 0 deletions Sources/KeyPathAppKit/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))
}
Expand Down
53 changes: 44 additions & 9 deletions Sources/KeyPathAppKit/Managers/ConfigReloadCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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)
Expand Down Expand Up @@ -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
Expand All @@ -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<String> = [
"com.figma.Desktop",
"com.figma.agent"
]
}

// MARK: - Initialization
Expand Down Expand Up @@ -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
Expand Down
14 changes: 6 additions & 8 deletions Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down
24 changes: 20 additions & 4 deletions Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -724,21 +733,28 @@ 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.
hostingView.invalidateIntrinsicContentSize()
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)
Expand Down
32 changes: 29 additions & 3 deletions Sources/KeyPathAppKit/UI/Gallery/GalleryWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
}
Expand Down
Loading
Loading