fix: improve talk overlay input + drag

This commit is contained in:
Peter Steinberger
2025-12-30 14:18:51 +01:00
parent 7d1ec51df5
commit 973bd3a427
2 changed files with 94 additions and 48 deletions

View File

@@ -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<TalkOverlayView>?
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<TalkOverlayView> {
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)
}
}

View File

@@ -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