From 9c532eac07fae10a788c7ae01cd3ec55110d8d8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 30 Dec 2025 11:37:52 +0100 Subject: [PATCH] feat(talk): pause + drag overlay orb --- .../Sources/Clawdis/TalkModeController.swift | 21 ++++- apps/macos/Sources/Clawdis/TalkOverlay.swift | 16 ++++ .../Sources/Clawdis/TalkOverlayView.swift | 79 +++++++++++++++---- 3 files changed, 98 insertions(+), 18 deletions(-) diff --git a/apps/macos/Sources/Clawdis/TalkModeController.swift b/apps/macos/Sources/Clawdis/TalkModeController.swift index 707b56995..85e3405ec 100644 --- a/apps/macos/Sources/Clawdis/TalkModeController.swift +++ b/apps/macos/Sources/Clawdis/TalkModeController.swift @@ -8,6 +8,9 @@ final class TalkModeController { private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.controller") + private(set) var phase: TalkModePhase = .idle + private(set) var isPaused: Bool = false + func setEnabled(_ enabled: Bool) async { self.logger.info("talk enabled=\(enabled)") if enabled { @@ -19,14 +22,30 @@ final class TalkModeController { } func updatePhase(_ phase: TalkModePhase) { + self.phase = phase TalkOverlayController.shared.updatePhase(phase) - Task { await GatewayConnection.shared.talkMode(enabled: AppStateStore.shared.talkEnabled, phase: phase.rawValue) } + let effectivePhase = self.isPaused ? "paused" : phase.rawValue + Task { await GatewayConnection.shared.talkMode(enabled: AppStateStore.shared.talkEnabled, phase: effectivePhase) } } func updateLevel(_ level: Double) { TalkOverlayController.shared.updateLevel(level) } + func setPaused(_ paused: Bool) { + guard self.isPaused != paused else { return } + self.logger.info("talk paused=\(paused)") + self.isPaused = paused + TalkOverlayController.shared.updatePaused(paused) + let effectivePhase = paused ? "paused" : self.phase.rawValue + Task { await GatewayConnection.shared.talkMode(enabled: AppStateStore.shared.talkEnabled, phase: effectivePhase) } + Task { await TalkModeRuntime.shared.setPaused(paused) } + } + + func togglePaused() { + self.setPaused(!self.isPaused) + } + func stopSpeaking(reason: TalkStopReason = .userTap) { Task { await TalkModeRuntime.shared.stopSpeaking(reason: reason) } } diff --git a/apps/macos/Sources/Clawdis/TalkOverlay.swift b/apps/macos/Sources/Clawdis/TalkOverlay.swift index 7f5ec7848..73fc144ac 100644 --- a/apps/macos/Sources/Clawdis/TalkOverlay.swift +++ b/apps/macos/Sources/Clawdis/TalkOverlay.swift @@ -15,6 +15,7 @@ final class TalkOverlayController { struct Model { var isVisible: Bool = false var phase: TalkModePhase = .idle + var isPaused: Bool = false var level: Double = 0 } @@ -73,11 +74,26 @@ final class TalkOverlayController { self.model.phase = phase } + func updatePaused(_ paused: Bool) { + guard self.model.isPaused != paused else { return } + self.logger.info("talk overlay paused=\(paused)") + self.model.isPaused = paused + } + func updateLevel(_ level: Double) { guard self.model.isVisible else { return } self.model.level = max(0, min(1, level)) } + func currentWindowOrigin() -> CGPoint? { + self.window?.frame.origin + } + + func setWindowOrigin(_ origin: CGPoint) { + guard let window else { return } + window.setFrameOrigin(origin) + } + // MARK: - Private private func ensureWindow() { diff --git a/apps/macos/Sources/Clawdis/TalkOverlayView.swift b/apps/macos/Sources/Clawdis/TalkOverlayView.swift index d121ee3a6..9600a4ebf 100644 --- a/apps/macos/Sources/Clawdis/TalkOverlayView.swift +++ b/apps/macos/Sources/Clawdis/TalkOverlayView.swift @@ -4,21 +4,58 @@ struct TalkOverlayView: View { var controller: TalkOverlayController @State private var appState = AppStateStore.shared @State private var hoveringWindow = false + @State private var dragStartOrigin: CGPoint? + @State private var didDrag: Bool = false private static let orbCornerNudge: CGFloat = 12 var body: some View { ZStack(alignment: .topTrailing) { + let isPaused = self.controller.model.isPaused TalkOrbView( phase: self.controller.model.phase, level: self.controller.model.level, - accent: self.seamColor) + accent: self.seamColor, + isPaused: isPaused) .frame(width: 96, height: 96) .padding(.top, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge) .padding(.trailing, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge) .contentShape(Circle()) - .onTapGesture { - TalkModeController.shared.stopSpeaking(reason: .userTap) - } + .opacity(isPaused ? 0.55 : 1) + .highPriorityGesture( + TapGesture(count: 2).onEnded { + TalkModeController.shared.stopSpeaking(reason: .userTap) + }) + .highPriorityGesture( + TapGesture().onEnded { + if self.didDrag { return } + TalkModeController.shared.togglePaused() + }) + .simultaneousGesture( + DragGesture(minimumDistance: 1, coordinateSpace: .global) + .onChanged { value in + if self.dragStartOrigin == nil { + self.dragStartOrigin = self.controller.currentWindowOrigin() + self.didDrag = false + TalkModeController.shared.setPaused(true) + } + + if abs(value.translation.width) + abs(value.translation.height) > 2 { + self.didDrag = true + } + + guard let start = self.dragStartOrigin else { return } + let origin = CGPoint( + x: start.x + value.translation.width, + y: start.y - value.translation.height) + self.controller.setWindowOrigin(origin) + } + .onEnded { _ in + self.dragStartOrigin = nil + Task { @MainActor in + try? await Task.sleep(nanoseconds: 80_000_000) + self.didDrag = false + } + }) .overlay(alignment: .topLeading) { Button { TalkModeController.shared.exitTalkMode() @@ -65,24 +102,32 @@ private struct TalkOrbView: View { let phase: TalkModePhase let level: Double let accent: Color + let isPaused: Bool var body: some View { - TimelineView(.animation) { context in - let t = context.date.timeIntervalSinceReferenceDate - let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1 - let pulse = phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1 + if self.isPaused { + Circle() + .fill(self.orbGradient) + .overlay(Circle().stroke(Color.white.opacity(0.35), lineWidth: 1)) + .shadow(color: Color.black.opacity(0.18), radius: 10, x: 0, y: 5) + } else { + TimelineView(.animation) { context in + let t = context.date.timeIntervalSinceReferenceDate + let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1 + let pulse = phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1 - ZStack { - Circle() - .fill(self.orbGradient) - .overlay(Circle().stroke(Color.white.opacity(0.45), lineWidth: 1)) - .shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5) - .scaleEffect(pulse * listenScale) + ZStack { + Circle() + .fill(self.orbGradient) + .overlay(Circle().stroke(Color.white.opacity(0.45), lineWidth: 1)) + .shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5) + .scaleEffect(pulse * listenScale) - TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent) + TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent) - if phase == .thinking { - TalkOrbitArcs(time: t) + if phase == .thinking { + TalkOrbitArcs(time: t) + } } } }