Skip to content
Open
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
62 changes: 58 additions & 4 deletions leanring-buddy/OverlayWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ struct BlueCursorView: View {
@State private var bubbleOpacity: Double = 1.0
@State private var cursorOpacity: Double = 0.0

// Idle dim state — separate from cursorOpacity so the initial 0→1 fade-in is unaffected
@State private var cursorIdleDimFactor: Double = 1.0
@State private var lastTrackedMousePosition: CGPoint = .zero
@State private var lastCursorMovementDate: Date = Date()
private let idleDimTimeoutSeconds: Double = 0.7
private let idleDimmedOpacity: Double = 0.5
private let idleShrinkScale: Double = 0.8

// MARK: - Buddy Navigation State

/// The buddy's current behavioral mode (following cursor, navigating, or pointing).
Expand Down Expand Up @@ -307,8 +315,8 @@ struct BlueCursorView: View {
.frame(width: 16, height: 16)
.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)
.scaleEffect(buddyFlightScale * (cursorIdleDimFactor < 1.0 ? idleShrinkScale : 1.0))
.opacity(buddyIsVisibleOnThisScreen && (companionManager.voiceState == .idle || companionManager.voiceState == .responding) ? cursorOpacity * cursorIdleDimFactor : 0)
.position(cursorPosition)
.animation(
buddyNavigationMode == .followingCursor
Expand All @@ -321,20 +329,34 @@ struct BlueCursorView: View {
buddyNavigationMode == .navigatingToTarget ? nil : .easeInOut(duration: 0.3),
value: triangleRotationDegrees
)
.animation(
cursorIdleDimFactor < 1.0 ? .easeInOut(duration: 1.0) : .easeInOut(duration: 0.4),
value: cursorIdleDimFactor
)

// Blue waveform — replaces the triangle while listening
BlueCursorWaveformView(audioPowerLevel: companionManager.currentAudioPowerLevel)
.opacity(buddyIsVisibleOnThisScreen && companionManager.voiceState == .listening ? cursorOpacity : 0)
.scaleEffect(cursorIdleDimFactor < 1.0 ? idleShrinkScale : 1.0)
.opacity(buddyIsVisibleOnThisScreen && companionManager.voiceState == .listening ? cursorOpacity * cursorIdleDimFactor : 0)
.position(cursorPosition)
.animation(.spring(response: 0.2, dampingFraction: 0.6, blendDuration: 0), value: cursorPosition)
.animation(.easeIn(duration: 0.15), value: companionManager.voiceState)
.animation(
cursorIdleDimFactor < 1.0 ? .easeInOut(duration: 1.0) : .easeInOut(duration: 0.4),
value: cursorIdleDimFactor
)

// Blue spinner — shown while the AI is processing (transcription + Claude + waiting for TTS)
BlueCursorSpinnerView()
.opacity(buddyIsVisibleOnThisScreen && companionManager.voiceState == .processing ? cursorOpacity : 0)
.scaleEffect(cursorIdleDimFactor < 1.0 ? idleShrinkScale : 1.0)
.opacity(buddyIsVisibleOnThisScreen && companionManager.voiceState == .processing ? cursorOpacity * cursorIdleDimFactor : 0)
.position(cursorPosition)
.animation(.spring(response: 0.2, dampingFraction: 0.6, blendDuration: 0), value: cursorPosition)
.animation(.easeIn(duration: 0.15), value: companionManager.voiceState)
.animation(
cursorIdleDimFactor < 1.0 ? .easeInOut(duration: 1.0) : .easeInOut(duration: 0.4),
value: cursorIdleDimFactor
)

}
.frame(width: screenFrame.width, height: screenFrame.height)
Expand All @@ -349,6 +371,10 @@ struct BlueCursorView: View {

startTrackingCursor()

// Seed the last known mouse position and timestamp for idle dim tracking
lastTrackedMousePosition = mouseLocation
lastCursorMovementDate = Date()

// Only show welcome message on first appearance (app start)
// and only if the cursor starts on this screen
if isFirstAppearance && isCursorOnThisScreen {
Expand Down Expand Up @@ -406,12 +432,40 @@ struct BlueCursorView: View {
}
}

// MARK: - Idle Dim

/// Called every cursor-tracking frame. Dims the cursor after 30 seconds of no movement
/// and restores it immediately when the cursor moves again.
private func handleCursorMovementForIdleDimming(currentMouseLocation: CGPoint) {
let movementDistance = hypot(
currentMouseLocation.x - lastTrackedMousePosition.x,
currentMouseLocation.y - lastTrackedMousePosition.y
)

if movementDistance > 2.0 {
// Cursor moved — record position and time, restore opacity if dimmed
lastTrackedMousePosition = currentMouseLocation
lastCursorMovementDate = Date()

if cursorIdleDimFactor < 1.0 {
cursorIdleDimFactor = 1.0
}
} else {
// Cursor is still — dim after the idle timeout has elapsed
let secondsIdle = Date().timeIntervalSince(lastCursorMovementDate)
if secondsIdle >= idleDimTimeoutSeconds && cursorIdleDimFactor >= 1.0 {
cursorIdleDimFactor = idleDimmedOpacity
}
}
}

// MARK: - Cursor Tracking

private func startTrackingCursor() {
timer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { _ in
let mouseLocation = NSEvent.mouseLocation
self.isCursorOnThisScreen = self.screenFrame.contains(mouseLocation)
self.handleCursorMovementForIdleDimming(currentMouseLocation: mouseLocation)

// During forward flight or pointing, the buddy is NOT interrupted by
// mouse movement — it completes its full animation and return flight.
Expand Down