diff --git a/Sources/KeyPathAppKit/App.swift b/Sources/KeyPathAppKit/App.swift index 18da71d2b..55361c507 100644 --- a/Sources/KeyPathAppKit/App.swift +++ b/Sources/KeyPathAppKit/App.swift @@ -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") diff --git a/Sources/KeyPathAppKit/InstallationWizard/WizardWindowController.swift b/Sources/KeyPathAppKit/InstallationWizard/WizardWindowController.swift index d7566ce9c..b597506d0 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/WizardWindowController.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/WizardWindowController.swift @@ -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() } diff --git a/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift b/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift index 7a9d6f1ad..e06d93840 100644 --- a/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift +++ b/Sources/KeyPathAppKit/Services/Configuration/PreferencesService.swift @@ -310,6 +310,15 @@ final class PreferencesService: @unchecked Sendable { } } + /// 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) @@ -496,6 +505,7 @@ final class PreferencesService: @unchecked Sendable { 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" } @@ -516,6 +526,7 @@ final class PreferencesService: @unchecked Sendable { 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. @@ -581,6 +592,8 @@ final class PreferencesService: @unchecked Sendable { ) 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 = diff --git a/Sources/KeyPathAppKit/UI/Help/HelpWindowController.swift b/Sources/KeyPathAppKit/UI/Help/HelpWindowController.swift index ca2edd56a..e1f4f5cf1 100644 --- a/Sources/KeyPathAppKit/UI/Help/HelpWindowController.swift +++ b/Sources/KeyPathAppKit/UI/Help/HelpWindowController.swift @@ -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 } } diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController+LayerReveal.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController+LayerReveal.swift index 388eb8873..c53f3f21b 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController+LayerReveal.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController+LayerReveal.swift @@ -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", @@ -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() { diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift index 11f9f4908..81e0c81c3 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift @@ -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 @@ -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 { @@ -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() { diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayLayerRevealPolicy.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayLayerRevealPolicy.swift index 8877b5f04..497f03cfb 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayLayerRevealPolicy.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayLayerRevealPolicy.swift @@ -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 @@ -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) diff --git a/Sources/KeyPathAppKit/UI/Rules/FirstSuccessOnboardingWindowController.swift b/Sources/KeyPathAppKit/UI/Rules/FirstSuccessOnboardingWindowController.swift index 4d46c9a1c..22f774530 100644 --- a/Sources/KeyPathAppKit/UI/Rules/FirstSuccessOnboardingWindowController.swift +++ b/Sources/KeyPathAppKit/UI/Rules/FirstSuccessOnboardingWindowController.swift @@ -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() } diff --git a/Sources/KeyPathAppKit/UI/Settings/SettingsView+General.swift b/Sources/KeyPathAppKit/UI/Settings/SettingsView+General.swift index 040cb3f47..6112b5004 100644 --- a/Sources/KeyPathAppKit/UI/Settings/SettingsView+General.swift +++ b/Sources/KeyPathAppKit/UI/Settings/SettingsView+General.swift @@ -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" + ) } } } diff --git a/Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift b/Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift index d723b02a7..c5446373a 100644 --- a/Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift +++ b/Tests/KeyPathTests/Services/PreferencesServicePersistenceTests.swift @@ -25,6 +25,7 @@ final class PreferencesServicePersistenceTests: XCTestCase { "KeyPath.Education.OverlayHiddenHintShowCount", "KeyPath.Overlay.SuppressedBundleIDs", "KeyPath.Overlay.Opacity", + "KeyPath.Overlay.ShowsOnlyWhileLayerHeld", "KeyPath.Overlay.FadesWhenIdle", ] @@ -245,6 +246,15 @@ final class PreferencesServicePersistenceTests: XCTestCase { } } + func testShowOnlyWhileLayerHeldIsOffByDefaultAndPersists() { + withPreservedDefaults { + let writer = PreferencesService() + XCTAssertFalse(writer.overlayShowsOnlyWhileLayerHeld) + writer.overlayShowsOnlyWhileLayerHeld = true + XCTAssertTrue(PreferencesService().overlayShowsOnlyWhileLayerHeld) + } + } + func testContextHUDHoldDelayCustomMs_ClampedToMinimum() { let prefs = PreferencesService() prefs.contextHUDHoldDelayCustomMs = 50 diff --git a/Tests/KeyPathTests/UI/OverlayLayerRevealPolicyTests.swift b/Tests/KeyPathTests/UI/OverlayLayerRevealPolicyTests.swift index 5d2ed5fbc..72031ad21 100644 --- a/Tests/KeyPathTests/UI/OverlayLayerRevealPolicyTests.swift +++ b/Tests/KeyPathTests/UI/OverlayLayerRevealPolicyTests.swift @@ -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)) + } } diff --git a/guides/general-settings.md b/guides/general-settings.md index 15000718e..ea633ef7e 100644 --- a/guides/general-settings.md +++ b/guides/general-settings.md @@ -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.