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
2 changes: 1 addition & 1 deletion Sources/KeyPathAppKit/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// auto-hide), dismiss it — a configured app must never strand it.
mainWindowController?.hideSplash()
LiveKeyboardOverlayController.shared.clearExplicitHideFlag()
LiveKeyboardOverlayController.shared.showForStartup(bypassHiddenCheck: true)
LiveKeyboardOverlayController.shared.showForStartup(bypassHiddenCheck: true, userRequested: true)

case .showSplash:
AppLogger.shared.info("🪟 [AppDelegate] \(trigger): setup incomplete — showing wizard")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ final class WizardWindowController {
// The wizard only schedules this after verifying a healthy runtime.
// Complete the overlay gate before presenting the successful handoff.
overlay.setOnboardingCompleted(true)
overlay.showForStartup(bypassHiddenCheck: true)
overlay.showForStartup(bypassHiddenCheck: true, userRequested: true)
if !overlay.uiState.isInspectorOpen {
overlay.toggleInspectorPanel()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@
}
}

/// Manages KeyPath application preferences and settings

Check warning on line 171 in Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift

View workflow job for this annotation

GitHub Actions / code-quality

A doc comment should be attached to a declaration (orphaned_doc_comment)
// SAFETY: @unchecked Sendable — all mutable state is backed by UserDefaults (thread-safe)
// and the shared instance is confined to @MainActor. @Observable macro prevents conforming
// to Sendable directly; @unchecked bridges the gap.
Expand Down Expand Up @@ -310,6 +310,15 @@
}
}

/// When true the overlay stays hidden and appears only while a layer is held
/// (see OverlayLayerRevealPolicy); launch and app activation no longer open it.
var overlayShowsOnlyWhileLayerHeld: Bool {
didSet {
UserDefaults.standard.set(overlayShowsOnlyWhileLayerHeld, forKey: Keys.overlayShowsOnlyWhileLayerHeld)
AppLogger.shared.log("🎨 [Preferences] overlayShowsOnlyWhileLayerHeld = \(overlayShowsOnlyWhileLayerHeld)")
}
}

static func clampedOverlayOpacity(_ value: Double) -> Double {
guard value.isFinite else { return 1.0 }
return min(max(value, 0.3), 1.0)
Expand Down Expand Up @@ -496,6 +505,7 @@
static let unmappedLayerKeyStyle = "KeyPath.Overlay.UnmappedLayerKeyStyle"
static let overlayOpacity = "KeyPath.Overlay.Opacity"
static let overlayFadesWhenIdle = "KeyPath.Overlay.FadesWhenIdle"
static let overlayShowsOnlyWhileLayerHeld = "KeyPath.Overlay.ShowsOnlyWhileLayerHeld"
static let overlayHiddenHintShowCount = "KeyPath.Education.OverlayHiddenHintShowCount"
static let overlaySuppressedBundleIDs = "KeyPath.Overlay.SuppressedBundleIDs"
}
Expand All @@ -516,6 +526,7 @@
static let unmappedLayerKeyStyle = UnmappedLayerKeyStyle.baseLayer
static let overlayOpacity = 1.0
static let overlayFadesWhenIdle = true
static let overlayShowsOnlyWhileLayerHeld = false
static let accessibilityTestMode = false
// Nothing pops up on a layer hold unless the user picks it: holding Space to
// type must not flash a window for people who never asked for one.
Expand Down Expand Up @@ -581,6 +592,8 @@
)
overlayFadesWhenIdle = UserDefaults.standard.object(forKey: Keys.overlayFadesWhenIdle) as? Bool
?? Defaults.overlayFadesWhenIdle
overlayShowsOnlyWhileLayerHeld = UserDefaults.standard.object(forKey: Keys.overlayShowsOnlyWhileLayerHeld) as? Bool
?? Defaults.overlayShowsOnlyWhileLayerHeld

// Testing preferences
accessibilityTestMode =
Expand Down
5 changes: 3 additions & 2 deletions Sources/KeyPathAppKit/UI/Help/HelpWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ final class HelpWindowController: NSObject, NSWindowDelegate {
if !keepOverlayVisible {
// Hide the overlay while help is open (restore on close)
let overlay = LiveKeyboardOverlayController.shared
overlayWasVisible = overlay.isVisible
if overlayWasVisible {
// A layer-revealed overlay is temporary: hide it, but don't reopen it on close.
overlayWasVisible = overlay.isVisibleByChoice
if overlay.isVisible {
overlay.isVisible = false
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ extension LiveKeyboardOverlayController {
let preferences = PreferencesService.shared
layerReveal.configuration = .make(
displayMode: preferences.contextHUDDisplayMode,
triggerMode: preferences.contextHUDTriggerMode
triggerMode: preferences.contextHUDTriggerMode,
showsOnlyWhileLayerHeld: preferences.overlayShowsOnlyWhileLayerHeld
)
let effects = layerReveal.layerChanged(
isBase: normalizedLayer == "base",
Expand Down Expand Up @@ -57,6 +58,16 @@ extension LiveKeyboardOverlayController {
}
}

/// "Show only while a layer is held" was switched on: put away an overlay the user
/// had open, and forget any pending restore, so it is hidden until a layer is held.
func overlayShowsOnlyWhileLayerHeldChanged(_ enabled: Bool) {
guard enabled else { return }
forgetPendingVisibilityRestores()
if isVisibleByChoice {
isVisible = false
}
}

/// Shows the overlay without touching the saved visibility, the explicit-hide flag
/// or app focus: an overlay revealed by a layer is not the user's open overlay.
private func revealForLayer() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate {
func restoreState() {
let defaults = UserDefaults.standard
guard defaults.bool(forKey: DefaultsKey.isVisible) else { return }
guard !PreferencesService.shared.overlayShowsOnlyWhileLayerHeld else { return }
guard canShowOverlay(reason: "restore state") else { return }

// Only restore if system is healthy
Expand All @@ -200,8 +201,18 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate {
/// Also starts health state observation to show validation progress.
/// - Parameter bypassHiddenCheck: If true, shows overlay even if user explicitly hid it.
/// Only use this for the initial app launch, not for subsequent activations.
func showForStartup(bypassHiddenCheck: Bool = false) {
/// - Parameter userRequested: The user asked for KeyPath (reopened it, or finished a
/// guided step that hands over to the keyboard). Otherwise launch and activation
/// leave the overlay hidden while "Show only while a layer is held" is on.
func showForStartup(bypassHiddenCheck: Bool = false, userRequested: Bool = false) {
guard canShowOverlay(reason: "startup show") else { return }
guard Self.startupShowAllowed(
showsOnlyWhileLayerHeld: PreferencesService.shared.overlayShowsOnlyWhileLayerHeld,
userRequested: userRequested
) else {
AppLogger.shared.log("⏸️ [OverlayController] showForStartup skipped - overlay shows only while a layer is held")
return
}

// IMPORTANT: Respect user's explicit hide unless bypassed (initial launch only)
if !bypassHiddenCheck, userExplicitlyHidden {
Expand Down Expand Up @@ -479,6 +490,16 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate {
}
}

nonisolated static func startupShowAllowed(showsOnlyWhileLayerHeld: Bool, userRequested: Bool) -> Bool {
userRequested || !showsOnlyWhileLayerHeld
}

/// Drop restores queued by Settings/Wizard or app suppression.
func forgetPendingVisibilityRestores() {
wasVisibleBeforeAutoHide = false
wasVisibleBeforeAppSuppression = false
}

/// Reset the auto-hide guard when Settings/Wizard closes.
/// Restores the overlay if it was visible before auto-hide.
func resetSettingsAutoHideGuard() {
Expand Down
10 changes: 8 additions & 2 deletions Sources/KeyPathAppKit/UI/Overlay/OverlayLayerRevealPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import Foundation
/// never flash it; it shows up when you pause because you need it.
/// - Tap trigger: tapping the layer key on is a request to see it, so it appears at once.
/// - It leaves alone an overlay the user opened themselves.
/// - "Show only while a layer is held" turns the reveal on whatever the
/// "While a Layer Is Active" choice is: it is how that mode shows the overlay at all.
struct OverlayLayerRevealPolicy: Equatable {
struct Configuration: Equatable {
var isEnabled: Bool
Expand All @@ -19,8 +21,12 @@ struct OverlayLayerRevealPolicy: Equatable {

static let disabled = Configuration(isEnabled: false, revealDelay: 0, cancelOnKeyPress: false)

static func make(displayMode: ContextHUDDisplayMode, triggerMode: ContextHUDTriggerMode) -> Configuration {
let showsOverlay = displayMode == .overlayOnly || displayMode == .both
static func make(
displayMode: ContextHUDDisplayMode,
triggerMode: ContextHUDTriggerMode,
showsOnlyWhileLayerHeld: Bool = false
) -> Configuration {
let showsOverlay = showsOnlyWhileLayerHeld || displayMode == .overlayOnly || displayMode == .both
switch triggerMode {
case .holdToShow:
return Configuration(isEnabled: showsOverlay, revealDelay: 0.35, cancelOnKeyPress: true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -834,7 +834,7 @@ extension FirstSuccessOnboardingWindowController: NSWindowDelegate {
let overlay = LiveKeyboardOverlayController.shared
overlay.resetSettingsAutoHideGuard()
overlay.setOnboardingCompleted(true)
overlay.showForStartup(bypassHiddenCheck: true)
overlay.showForStartup(bypassHiddenCheck: true, userRequested: true)
if !overlay.uiState.isInspectorOpen {
overlay.toggleInspectorPanel()
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/KeyPathAppKit/UI/Settings/SettingsView+General.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ struct GeneralSettingsTabView: View {
),
identifier: "settings-overlay-fade-when-idle"
)
SettingsToggleRow(
title: "Show when a layer is active",
subtitle: "Keep the overlay hidden on the base layer. Hold or toggle a layer to reveal it.",
isOn: Binding(
get: { services.preferences.overlayShowsOnlyWhileLayerHeld },
set: {
services.preferences.overlayShowsOnlyWhileLayerHeld = $0
LiveKeyboardOverlayController.shared.overlayShowsOnlyWhileLayerHeldChanged($0)
}
),
identifier: "settings-overlay-show-only-while-layer-held"
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"KeyPath.Education.OverlayHiddenHintShowCount",
"KeyPath.Overlay.SuppressedBundleIDs",
"KeyPath.Overlay.Opacity",
"KeyPath.Overlay.ShowsOnlyWhileLayerHeld",
"KeyPath.Overlay.FadesWhenIdle",

Check warning on line 29 in Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift

View workflow job for this annotation

GitHub Actions / code-quality

Collection literals should not have trailing commas (trailing_comma)
]

private func withPreservedDefaults<T>(
Expand Down Expand Up @@ -77,7 +78,7 @@
writer.kindaVimLeaderHUDMode = .cheatSheetOnly
writer.neovimReferenceTopics = Set([
NeovimTerminalCategory.basicMotions.rawValue,
NeovimTerminalCategory.search.rawValue,

Check warning on line 81 in Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift

View workflow job for this annotation

GitHub Actions / code-quality

Collection literals should not have trailing commas (trailing_comma)
])

let loaded = PreferencesService()
Expand All @@ -104,7 +105,7 @@
loaded.neovimReferenceTopics,
Set([
NeovimTerminalCategory.basicMotions.rawValue,
NeovimTerminalCategory.search.rawValue,

Check warning on line 108 in Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift

View workflow job for this annotation

GitHub Actions / code-quality

Collection literals should not have trailing commas (trailing_comma)
])
)
}
Expand Down Expand Up @@ -160,7 +161,7 @@
.verboseLoggingChanged,
.accessibilityTestModeChanged,
.overlaySuppressedBundleIDsChanged,
.configAffectingPreferenceChanged,

Check warning on line 164 in Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift

View workflow job for this annotation

GitHub Actions / code-quality

Collection literals should not have trailing commas (trailing_comma)
]
let observers = observedNames.map { name in
center.addObserver(forName: name, object: nil, queue: nil) { notification in
Expand Down Expand Up @@ -245,6 +246,15 @@
}
}

func testShowOnlyWhileLayerHeldIsOffByDefaultAndPersists() {
withPreservedDefaults {
let writer = PreferencesService()
XCTAssertFalse(writer.overlayShowsOnlyWhileLayerHeld)
writer.overlayShowsOnlyWhileLayerHeld = true
XCTAssertTrue(PreferencesService().overlayShowsOnlyWhileLayerHeld)
}
}

func testContextHUDHoldDelayCustomMs_ClampedToMinimum() {
let prefs = PreferencesService()
prefs.contextHUDHoldDelayCustomMs = 50
Expand Down Expand Up @@ -379,7 +389,7 @@
}

func testLeaderKeyPreference_InvalidJsonFallsBackToDefault() {
let invalidData = "not json".data(using: .utf8)!

Check warning on line 392 in Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift

View workflow job for this annotation

GitHub Actions / code-quality

Prefer non-optional `Data(_:)` initializer when converting `String` to `Data` (non_optional_string_data_conversion)
let decoded = try? JSONDecoder().decode(LeaderKeyPreference.self, from: invalidData)
XCTAssertNil(decoded, "Invalid JSON should fail to decode")
}
Expand Down
65 changes: 65 additions & 0 deletions Tests/KeyPathTests/UI/OverlayLayerRevealPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,69 @@ final class OverlayLayerRevealPolicyTests: XCTestCase {
XCTAssertEqual(policy.layerChanged(isBase: false, overlayAlreadyVisible: false), [], "\(mode)")
}
}

// MARK: - Show only while a layer is held

private func heldOnlyPolicy(
displayMode: ContextHUDDisplayMode = .none,
triggerMode: ContextHUDTriggerMode = .holdToShow
) -> OverlayLayerRevealPolicy {
OverlayLayerRevealPolicy(configuration: .make(
displayMode: displayMode,
triggerMode: triggerMode,
showsOnlyWhileLayerHeld: true
))
}

func testHeldOnlyRevealsEvenWhenTheLayerChoiceIsNothingOrList() {
for mode in ContextHUDDisplayMode.allCases {
var policy = heldOnlyPolicy(displayMode: mode)
XCTAssertEqual(policy.layerChanged(isBase: false, overlayAlreadyVisible: false), [.scheduleReveal(after: 0.35)], "\(mode)")
XCTAssertEqual(policy.revealTimerFired(), [.show], "\(mode)")
}
}

func testHeldOnlyHidesShortlyAfterReturningToBase() {
var policy = heldOnlyPolicy()
_ = policy.layerChanged(isBase: false, overlayAlreadyVisible: false)
_ = policy.revealTimerFired()
XCTAssertEqual(policy.layerChanged(isBase: true, overlayAlreadyVisible: true), [.scheduleConceal(after: 0.15)])
XCTAssertEqual(policy.concealTimerFired(), [.hide])
XCTAssertEqual(policy.state, .idle)
}

func testHeldOnlyIgnoresAQuickTapThatBarelyEntersALayer() {
var policy = heldOnlyPolicy()
_ = policy.layerChanged(isBase: false, overlayAlreadyVisible: false)
XCTAssertEqual(policy.layerChanged(isBase: true, overlayAlreadyVisible: false), [.cancelReveal])
XCTAssertEqual(policy.revealTimerFired(), [])
XCTAssertEqual(policy.state, .idle)
}

func testHeldOnlyStaysHiddenWhenTypingThroughALayer() {
var policy = heldOnlyPolicy()
_ = policy.layerChanged(isBase: false, overlayAlreadyVisible: false)
XCTAssertEqual(policy.keyPressed(), [.cancelReveal])
XCTAssertEqual(policy.revealTimerFired(), [])
XCTAssertEqual(policy.layerChanged(isBase: true, overlayAlreadyVisible: false), [])
// The next hold starts fresh.
XCTAssertEqual(policy.layerChanged(isBase: false, overlayAlreadyVisible: false), [.scheduleReveal(after: 0.35)])
}

func testHeldOnlyWithTapTriggerShowsAToggledLayerAtOnce() {
var policy = heldOnlyPolicy(triggerMode: .tapToToggle)
XCTAssertEqual(policy.layerChanged(isBase: false, overlayAlreadyVisible: false), [.show])
}

func testOffByDefaultKeepsTheNothingChoiceHidden() {
var policy = OverlayLayerRevealPolicy(configuration: .make(displayMode: .none, triggerMode: .holdToShow))
XCTAssertEqual(policy.layerChanged(isBase: false, overlayAlreadyVisible: false), [])
}

func testStartupShowsTheOverlayUnlessHeldOnlyIsOn() {
XCTAssertTrue(LiveKeyboardOverlayController.startupShowAllowed(showsOnlyWhileLayerHeld: false, userRequested: false))
XCTAssertFalse(LiveKeyboardOverlayController.startupShowAllowed(showsOnlyWhileLayerHeld: true, userRequested: false))
// Reopening KeyPath or a guided hand-off still opens it.
XCTAssertTrue(LiveKeyboardOverlayController.startupShowAllowed(showsOnlyWhileLayerHeld: true, userRequested: true))
}
}
3 changes: 2 additions & 1 deletion guides/general-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ Open KeyPath → Settings → General.
- The **Layer change notification** (a banner and sound) is here too.
- The same choice is in the menu bar under **While a Layer Is Active**.
- **Keyboard Appearance:** compare key-label previews, choose whether unmapped keys stay normal or dim, set how see-through the overlay is (**Overlay opacity**), and choose whether it fades after a while without typing (**Fade when idle**).
- Turn on **Show when a layer is active** if you want the overlay out of the way on the base layer. It stays hidden when KeyPath starts, appears when a hold, one-shot, or toggled layer becomes active, and hides shortly after returning to base. It follows the Hold or Tap choice above: with Hold, a quick move like Space then J doesn't bring it up. Ordinary typing and modifier keys never show it. You can still open it yourself with ⌥⌘K. Turning the setting off doesn't reopen the overlay; open it with ⌥⌘K when you want it.
- **Recording:** choose sequences or combinations, and whether recording captures physical keys or includes your mappings.
- **Keyboard Setup:** Caps Lock is set up automatically when your rules need it. With more than one keyboard connected, choose which one Caps Lock rules apply to ([details](driverless-caps-lock.md)). Manual keyboard setup is available under its disclosure. You can also import Karabiner rules here.
- **Keyboard Setup:** Caps Lock rules apply to every compatible keyboard by default, with per-keyboard exceptions and an advanced one-keyboard mode ([details](driverless-caps-lock.md)). Manual keyboard setup is available under its disclosure. You can also import Karabiner rules here.
- **Advanced:** expand Logs & Diagnostics or Virtual Keys when needed. Development builds also expose Experimental settings.

Appearance previews change only how keys are displayed. Recording choices apply to recording new rules.
Expand Down
Loading