fix: improve talk overlay input + drag
This commit is contained in:
@@ -8,7 +8,9 @@ import SwiftUI
|
|||||||
final class TalkOverlayController {
|
final class TalkOverlayController {
|
||||||
static let shared = TalkOverlayController()
|
static let shared = TalkOverlayController()
|
||||||
static let overlaySize: CGFloat = 440
|
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")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.overlay")
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ final class TalkOverlayController {
|
|||||||
var model = Model()
|
var model = Model()
|
||||||
private var window: NSPanel?
|
private var window: NSPanel?
|
||||||
private var hostingView: NSHostingView<TalkOverlayView>?
|
private var hostingView: NSHostingView<TalkOverlayView>?
|
||||||
private let padding: CGFloat = 8
|
private let screenInset: CGFloat = 0
|
||||||
|
|
||||||
func present() {
|
func present() {
|
||||||
self.ensureWindow()
|
self.ensureWindow()
|
||||||
@@ -115,7 +117,7 @@ final class TalkOverlayController {
|
|||||||
panel.titleVisibility = .hidden
|
panel.titleVisibility = .hidden
|
||||||
panel.titlebarAppearsTransparent = true
|
panel.titlebarAppearsTransparent = true
|
||||||
|
|
||||||
let host = NSHostingView(rootView: TalkOverlayView(controller: self))
|
let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self))
|
||||||
host.translatesAutoresizingMaskIntoConstraints = false
|
host.translatesAutoresizingMaskIntoConstraints = false
|
||||||
panel.contentView = host
|
panel.contentView = host
|
||||||
self.hostingView = host
|
self.hostingView = host
|
||||||
@@ -127,8 +129,21 @@ final class TalkOverlayController {
|
|||||||
let size = NSSize(width: Self.overlaySize, height: Self.overlaySize)
|
let size = NSSize(width: Self.overlaySize, height: Self.overlaySize)
|
||||||
let visible = screen.visibleFrame
|
let visible = screen.visibleFrame
|
||||||
let origin = CGPoint(
|
let origin = CGPoint(
|
||||||
x: visible.maxX - size.width - self.padding + Self.windowInset,
|
x: visible.maxX - size.width - self.screenInset,
|
||||||
y: visible.maxY - size.height - self.padding + Self.windowInset)
|
y: visible.maxY - size.height - self.screenInset)
|
||||||
return NSRect(origin: origin, size: size)
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct TalkOverlayView: View {
|
struct TalkOverlayView: View {
|
||||||
var controller: TalkOverlayController
|
var controller: TalkOverlayController
|
||||||
@State private var appState = AppStateStore.shared
|
@State private var appState = AppStateStore.shared
|
||||||
@State private var hoveringWindow = false
|
@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 {
|
var body: some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
@@ -16,46 +14,16 @@ struct TalkOverlayView: View {
|
|||||||
level: self.controller.model.level,
|
level: self.controller.model.level,
|
||||||
accent: self.seamColor,
|
accent: self.seamColor,
|
||||||
isPaused: isPaused)
|
isPaused: isPaused)
|
||||||
.frame(width: 96, height: 96)
|
.frame(width: TalkOverlayController.orbSize, height: TalkOverlayController.orbSize)
|
||||||
.padding(.top, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge)
|
.padding(.top, TalkOverlayController.orbPadding)
|
||||||
.padding(.trailing, 6 + TalkOverlayController.windowInset - Self.orbCornerNudge)
|
.padding(.trailing, TalkOverlayController.orbPadding)
|
||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
.opacity(isPaused ? 0.55 : 1)
|
.opacity(isPaused ? 0.55 : 1)
|
||||||
.highPriorityGesture(
|
.background(
|
||||||
TapGesture(count: 2).onEnded {
|
TalkOrbInteractionView(
|
||||||
TalkModeController.shared.stopSpeaking(reason: .userTap)
|
onSingleClick: { TalkModeController.shared.togglePaused() },
|
||||||
})
|
onDoubleClick: { TalkModeController.shared.stopSpeaking(reason: .userTap) },
|
||||||
.highPriorityGesture(
|
onDragStart: { TalkModeController.shared.setPaused(true) }))
|
||||||
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) {
|
.overlay(alignment: .topLeading) {
|
||||||
Button {
|
Button {
|
||||||
TalkModeController.shared.exitTalkMode()
|
TalkModeController.shared.exitTalkMode()
|
||||||
@@ -74,10 +42,9 @@ struct TalkOverlayView: View {
|
|||||||
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
|
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
|
||||||
.allowsHitTesting(self.hoveringWindow)
|
.allowsHitTesting(self.hoveringWindow)
|
||||||
}
|
}
|
||||||
|
.onHover { self.hoveringWindow = $0 }
|
||||||
}
|
}
|
||||||
.frame(width: TalkOverlayController.overlaySize, height: TalkOverlayController.overlaySize, alignment: .center)
|
.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)
|
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 {
|
private struct TalkOrbView: View {
|
||||||
let phase: TalkModePhase
|
let phase: TalkModePhase
|
||||||
let level: Double
|
let level: Double
|
||||||
|
|||||||
Reference in New Issue
Block a user