fix: improve talk overlay input + drag
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user