diff --git a/apps/macos/Sources/Clawdis/TalkOverlay.swift b/apps/macos/Sources/Clawdis/TalkOverlay.swift index 73fc144ac..d99f45dbe 100644 --- a/apps/macos/Sources/Clawdis/TalkOverlay.swift +++ b/apps/macos/Sources/Clawdis/TalkOverlay.swift @@ -8,7 +8,9 @@ import SwiftUI final class TalkOverlayController { static let shared = TalkOverlayController() static let overlaySize: CGFloat = 440 - static let windowInset: CGFloat = 88 + static let orbSize: CGFloat = 96 + static let orbPadding: CGFloat = 12 + static let orbHitSlop: CGFloat = 10 private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.overlay") @@ -22,7 +24,7 @@ final class TalkOverlayController { var model = Model() private var window: NSPanel? private var hostingView: NSHostingView? - private let padding: CGFloat = 8 + private let screenInset: CGFloat = 0 func present() { self.ensureWindow() @@ -115,7 +117,7 @@ final class TalkOverlayController { panel.titleVisibility = .hidden panel.titlebarAppearsTransparent = true - let host = NSHostingView(rootView: TalkOverlayView(controller: self)) + let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self)) host.translatesAutoresizingMaskIntoConstraints = false panel.contentView = host self.hostingView = host @@ -127,8 +129,21 @@ final class TalkOverlayController { let size = NSSize(width: Self.overlaySize, height: Self.overlaySize) let visible = screen.visibleFrame let origin = CGPoint( - x: visible.maxX - size.width - self.padding + Self.windowInset, - y: visible.maxY - size.height - self.padding + Self.windowInset) + x: visible.maxX - size.width - self.screenInset, + y: visible.maxY - size.height - self.screenInset) return NSRect(origin: origin, size: size) } } + +private final class TalkOverlayHostingView: NSHostingView { + override func hitTest(_ point: NSPoint) -> NSView? { + let center = CGPoint( + x: self.bounds.maxX - TalkOverlayController.orbPadding - (TalkOverlayController.orbSize / 2), + y: self.bounds.maxY - TalkOverlayController.orbPadding - (TalkOverlayController.orbSize / 2)) + let radius = (TalkOverlayController.orbSize / 2) + TalkOverlayController.orbHitSlop + let dx = point.x - center.x + let dy = point.y - center.y + guard dx * dx + dy * dy <= radius * radius else { return nil } + return super.hitTest(point) + } +} diff --git a/apps/macos/Sources/Clawdis/TalkOverlayView.swift b/apps/macos/Sources/Clawdis/TalkOverlayView.swift index 9600a4ebf..6d886a608 100644 --- a/apps/macos/Sources/Clawdis/TalkOverlayView.swift +++ b/apps/macos/Sources/Clawdis/TalkOverlayView.swift @@ -1,12 +1,10 @@ +import AppKit import SwiftUI 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) { @@ -16,46 +14,16 @@ struct TalkOverlayView: View { level: self.controller.model.level, accent: self.seamColor, isPaused: isPaused) - .frame(width: 96, height: 96) - .padding(.top, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge) - .padding(.trailing, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge) + .frame(width: TalkOverlayController.orbSize, height: TalkOverlayController.orbSize) + .padding(.top, TalkOverlayController.orbPadding) + .padding(.trailing, TalkOverlayController.orbPadding) .contentShape(Circle()) .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 - } - }) + .background( + TalkOrbInteractionView( + onSingleClick: { TalkModeController.shared.togglePaused() }, + onDoubleClick: { TalkModeController.shared.stopSpeaking(reason: .userTap) }, + onDragStart: { TalkModeController.shared.setPaused(true) })) .overlay(alignment: .topLeading) { Button { TalkModeController.shared.exitTalkMode() @@ -74,10 +42,9 @@ struct TalkOverlayView: View { .animation(.easeOut(duration: 0.12), value: self.hoveringWindow) .allowsHitTesting(self.hoveringWindow) } + .onHover { self.hoveringWindow = $0 } } .frame(width: TalkOverlayController.overlaySize, height: TalkOverlayController.overlaySize, alignment: .center) - .contentShape(Rectangle()) - .onHover { self.hoveringWindow = $0 } } private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) @@ -98,6 +65,70 @@ struct TalkOverlayView: View { } } +private struct TalkOrbInteractionView: NSViewRepresentable { + let onSingleClick: () -> Void + let onDoubleClick: () -> Void + let onDragStart: () -> Void + + func makeNSView(context: Context) -> NSView { + let view = OrbInteractionNSView() + view.onSingleClick = self.onSingleClick + view.onDoubleClick = self.onDoubleClick + view.onDragStart = self.onDragStart + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.clear.cgColor + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + guard let view = nsView as? OrbInteractionNSView else { return } + view.onSingleClick = self.onSingleClick + view.onDoubleClick = self.onDoubleClick + view.onDragStart = self.onDragStart + } +} + +private final class OrbInteractionNSView: NSView { + var onSingleClick: (() -> Void)? + var onDoubleClick: (() -> Void)? + var onDragStart: (() -> Void)? + private var mouseDownEvent: NSEvent? + private var didDrag = false + private var suppressSingleClick = false + + override var acceptsFirstResponder: Bool { true } + + override func mouseDown(with event: NSEvent) { + self.mouseDownEvent = event + self.didDrag = false + self.suppressSingleClick = event.clickCount > 1 + if event.clickCount == 2 { + self.onDoubleClick?() + } + } + + override func mouseDragged(with event: NSEvent) { + guard let startEvent = self.mouseDownEvent else { return } + if !self.didDrag { + let dx = event.locationInWindow.x - startEvent.locationInWindow.x + let dy = event.locationInWindow.y - startEvent.locationInWindow.y + if abs(dx) + abs(dy) < 2 { return } + self.didDrag = true + self.onDragStart?() + self.window?.performDrag(with: startEvent) + } + } + + override func mouseUp(with event: NSEvent) { + if !self.didDrag && !self.suppressSingleClick { + self.onSingleClick?() + } + self.mouseDownEvent = nil + self.didDrag = false + self.suppressSingleClick = false + } +} + private struct TalkOrbView: View { let phase: TalkModePhase let level: Double