From 3a5bca4c6894c2464d671d978624fe2ef5330503 Mon Sep 17 00:00:00 2001 From: Aura - jc <67582323+Catafal@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:50:33 +0200 Subject: [PATCH] Hide cursor overlay while user is typing (issue #37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors macOS NSCursor.setHiddenUntilMouseMoves(true) behavior: the Clicky cursor now hides on any keypress and reappears on mouse move. - GlobalPushToTalkShortcutMonitor: publishes isUserTyping = true on any non-PTT keyDown event (CGEvent tap already received these; now acts on them) - CompanionManager: forwards isUserTyping via Combine; exposes resetUserTyping() - OverlayWindow: shouldHideForTyping gates triangle opacity during idle state; 16ms cursor timer calls resetUserTyping() when mouse moves >1pt PTT shortcut (ctrl+option) uses .flagsChanged not .keyDown — unaffected. Voice interaction states (.listening/.processing/.responding) are never hidden. Fixes #37 Co-Authored-By: Claude Sonnet 4.6 --- leanring-buddy/CompanionManager.swift | 25 ++++++++++++++++ .../GlobalPushToTalkShortcutMonitor.swift | 16 ++++++++++ leanring-buddy/OverlayWindow.swift | 30 ++++++++++++++++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/leanring-buddy/CompanionManager.swift b/leanring-buddy/CompanionManager.swift index 0234cf19..7457d193 100644 --- a/leanring-buddy/CompanionManager.swift +++ b/leanring-buddy/CompanionManager.swift @@ -91,6 +91,7 @@ final class CompanionManager: ObservableObject { private var shortcutTransitionCancellable: AnyCancellable? private var voiceStateCancellable: AnyCancellable? private var audioPowerCancellable: AnyCancellable? + private var typingStateCancellable: AnyCancellable? private var accessibilityCheckTimer: Timer? private var pendingKeyboardShortcutStartTask: Task? /// Scheduled hide for transient cursor mode — cancelled if the user @@ -107,6 +108,11 @@ final class CompanionManager: ObservableObject { /// Used by the panel to show accurate status text ("Active" vs "Ready"). @Published private(set) var isOverlayVisible: Bool = false + /// True while the user is actively typing (non-PTT keypress detected). + /// Forwarded from GlobalPushToTalkShortcutMonitor and used by BlueCursorView + /// to hide the cursor during idle typing, mirroring macOS hide-on-type behavior. + @Published private(set) var isUserTyping: Bool = false + /// The Claude model used for voice responses. Persisted to UserDefaults. @Published var selectedModel: String = UserDefaults.standard.string(forKey: "selectedClaudeModel") ?? "claude-sonnet-4-6" @@ -179,6 +185,7 @@ final class CompanionManager: ObservableObject { bindVoiceStateObservation() bindAudioPowerLevel() bindShortcutTransitions() + bindTypingState() // Eagerly touch the Claude API so its TLS warmup handshake completes // well before the onboarding demo fires at ~40s into the video. _ = claudeAPI @@ -298,10 +305,18 @@ final class CompanionManager: ObservableObject { shortcutTransitionCancellable?.cancel() voiceStateCancellable?.cancel() audioPowerCancellable?.cancel() + typingStateCancellable?.cancel() accessibilityCheckTimer?.invalidate() accessibilityCheckTimer = nil } + /// Called by BlueCursorView's cursor-tracking timer when the mouse moves + /// after a period of typing, restoring the cursor — mirrors the "until mouse + /// moves" part of NSCursor.setHiddenUntilMouseMoves(true). + func resetUserTyping() { + isUserTyping = false + } + func refreshAllPermissions() { let previouslyHadAccessibility = hasAccessibilityPermission let previouslyHadScreenRecording = hasScreenRecordingPermission @@ -470,6 +485,16 @@ final class CompanionManager: ObservableObject { } } + /// Forwards the monitor's isUserTyping flag onto CompanionManager so + /// BlueCursorView can observe a single source-of-truth for cursor hiding. + private func bindTypingState() { + typingStateCancellable = globalPushToTalkShortcutMonitor.$isUserTyping + .receive(on: DispatchQueue.main) + .sink { [weak self] typing in + self?.isUserTyping = typing + } + } + private func handleShortcutTransition(_ transition: BuddyPushToTalkShortcut.ShortcutTransition) { switch transition { case .pressed: diff --git a/leanring-buddy/GlobalPushToTalkShortcutMonitor.swift b/leanring-buddy/GlobalPushToTalkShortcutMonitor.swift index 8020269b..bfd2a74d 100644 --- a/leanring-buddy/GlobalPushToTalkShortcutMonitor.swift +++ b/leanring-buddy/GlobalPushToTalkShortcutMonitor.swift @@ -23,6 +23,12 @@ final class GlobalPushToTalkShortcutMonitor: ObservableObject { /// waiting for the async dictation state pipeline to catch up. @Published private(set) var isShortcutCurrentlyPressed = false + /// True while the user is typing regular keys (non-PTT). + /// Used by BlueCursorView to hide the cursor overlay during typing, + /// mirroring macOS NSCursor.setHiddenUntilMouseMoves(true) behavior. + /// Reset to false when the mouse moves (detected in BlueCursorView's 16ms timer). + @Published private(set) var isUserTyping = false + deinit { stop() } @@ -85,6 +91,7 @@ final class GlobalPushToTalkShortcutMonitor: ObservableObject { func stop() { isShortcutCurrentlyPressed = false + isUserTyping = false if let globalEventTapRunLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), globalEventTapRunLoopSource, .commonModes) @@ -127,6 +134,15 @@ final class GlobalPushToTalkShortcutMonitor: ObservableObject { shortcutTransitionPublisher.send(.released) } + // When a regular (non-PTT) key is pressed, signal that the user is typing + // so the cursor overlay hides — mirroring NSCursor.setHiddenUntilMouseMoves. + // The PTT shortcut (ctrl+option) uses .flagsChanged not .keyDown, so it + // never reaches this branch. We also guard against PTT-combo keydowns via + // shortcutTransition == .none to be safe. + if eventType == .keyDown && shortcutTransition == .none { + isUserTyping = true + } + return Unmanaged.passUnretained(event) } } diff --git a/leanring-buddy/OverlayWindow.swift b/leanring-buddy/OverlayWindow.swift index 884ebcbf..190ac373 100644 --- a/leanring-buddy/OverlayWindow.swift +++ b/leanring-buddy/OverlayWindow.swift @@ -122,6 +122,9 @@ struct BlueCursorView: View { let localY = screenFrame.height - (mouseLocation.y - screenFrame.origin.y) _cursorPosition = State(initialValue: CGPoint(x: localX + 35, y: localY + 25)) _isCursorOnThisScreen = State(initialValue: screenFrame.contains(mouseLocation)) + // Seed typing-reset tracker from current mouse location so the first + // mouse move after a keypress is detected correctly from the start. + _lastMouseLocationForTypingDetection = State(initialValue: mouseLocation) } @State private var timer: Timer? @State private var welcomeText: String = "" @@ -129,6 +132,9 @@ struct BlueCursorView: View { @State private var bubbleSize: CGSize = .zero @State private var bubbleOpacity: Double = 1.0 @State private var cursorOpacity: Double = 0.0 + /// Tracks the previous mouse location so we can detect movement and reset + /// the typing-hide state — matching NSCursor.setHiddenUntilMouseMoves(true). + @State private var lastMouseLocationForTypingDetection: CGPoint = .zero // MARK: - Buddy Navigation State @@ -308,7 +314,7 @@ struct BlueCursorView: View { .rotationEffect(.degrees(triangleRotationDegrees)) .shadow(color: DS.Colors.overlayCursorBlue, radius: 8 + (buddyFlightScale - 1.0) * 20, x: 0, y: 0) .scaleEffect(buddyFlightScale) - .opacity(buddyIsVisibleOnThisScreen && (companionManager.voiceState == .idle || companionManager.voiceState == .responding) ? cursorOpacity : 0) + .opacity(buddyIsVisibleOnThisScreen && !shouldHideForTyping && (companionManager.voiceState == .idle || companionManager.voiceState == .responding) ? cursorOpacity : 0) .position(cursorPosition) .animation( buddyNavigationMode == .followingCursor @@ -386,6 +392,14 @@ struct BlueCursorView: View { } } + /// True when the cursor should be hidden because the user is typing in idle state. + /// Mirrors macOS NSCursor.setHiddenUntilMouseMoves(true) — hide on keypress, + /// reappear on mouse move. Never hides during voice interaction (listening/ + /// processing/responding) so the waveform and spinner stay visible. + private var shouldHideForTyping: Bool { + companionManager.isUserTyping && companionManager.voiceState == .idle + } + /// Whether the buddy triangle should be visible on this screen. /// True when cursor is on this screen during normal following, or /// when navigating/pointing at a target on this screen. When another @@ -413,6 +427,20 @@ struct BlueCursorView: View { let mouseLocation = NSEvent.mouseLocation self.isCursorOnThisScreen = self.screenFrame.contains(mouseLocation) + // When typing-hide is active, any mouse movement resets it — + // mirroring the "until mouse moves" part of NSCursor.setHiddenUntilMouseMoves. + // Runs before the navigation early-returns so it fires regardless of buddy mode. + if self.companionManager.isUserTyping { + let movementDistance = hypot( + mouseLocation.x - self.lastMouseLocationForTypingDetection.x, + mouseLocation.y - self.lastMouseLocationForTypingDetection.y + ) + if movementDistance > 1.0 { + self.companionManager.resetUserTyping() + } + } + self.lastMouseLocationForTypingDetection = mouseLocation + // During forward flight or pointing, the buddy is NOT interrupted by // mouse movement — it completes its full animation and return flight. // Only during the RETURN flight do we allow cursor movement to cancel