feat: refine voice wake overlay animations

main
Peter Steinberger 2025-12-08 16:32:38 +01:00
parent 764761cfa5
commit 8d2de036d5
1 changed files with 57 additions and 13 deletions

View File

@ -1,4 +1,5 @@
import AppKit import AppKit
import QuartzCore
import SwiftUI import SwiftUI
/// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar. /// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar.
@ -13,6 +14,7 @@ final class VoiceWakeOverlayController: ObservableObject {
var isFinal: Bool = false var isFinal: Bool = false
var isVisible: Bool = false var isVisible: Bool = false
var forwardEnabled: Bool = false var forwardEnabled: Bool = false
var isSending: Bool = false
} }
private var window: NSPanel? private var window: NSPanel?
@ -29,6 +31,7 @@ final class VoiceWakeOverlayController: ObservableObject {
self.model.text = transcript self.model.text = transcript
self.model.isFinal = false self.model.isFinal = false
self.model.forwardEnabled = false self.model.forwardEnabled = false
self.model.isSending = false
self.present() self.present()
} }
@ -38,16 +41,20 @@ final class VoiceWakeOverlayController: ObservableObject {
self.model.text = transcript self.model.text = transcript
self.model.isFinal = true self.model.isFinal = true
self.model.forwardEnabled = forwardConfig.enabled self.model.forwardEnabled = forwardConfig.enabled
self.model.isSending = false
self.present() self.present()
self.scheduleAutoSend() self.scheduleAutoSend()
} }
func userBeganEditing() { func userBeganEditing() {
self.autoSendTask?.cancel() self.autoSendTask?.cancel()
self.model.isSending = false
} }
func updateText(_ text: String) { func updateText(_ text: String) {
self.model.text = text self.model.text = text
self.model.isSending = false
self.updateWindowFrame(animate: true)
} }
func sendNow() { func sendNow() {
@ -62,18 +69,23 @@ final class VoiceWakeOverlayController: ObservableObject {
return return
} }
self.model.isSending = true
let payload = VoiceWakeForwarder.prefixedTranscript(text) let payload = VoiceWakeForwarder.prefixedTranscript(text)
Task.detached { Task.detached {
await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig) await VoiceWakeForwarder.forward(transcript: payload, config: forwardConfig)
} }
self.dismiss() DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
self.dismiss()
}
} }
func dismiss(reason: DismissReason = .explicit) { func dismiss(reason: DismissReason = .explicit) {
self.autoSendTask?.cancel() self.autoSendTask?.cancel()
self.model.isSending = false
guard let window else { return } guard let window else { return }
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18 context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().alphaValue = 0 window.animator().alphaValue = 0
} completionHandler: { } completionHandler: {
Task { @MainActor in Task { @MainActor in
@ -90,18 +102,23 @@ final class VoiceWakeOverlayController: ObservableObject {
private func present() { private func present() {
self.ensureWindow() self.ensureWindow()
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self) self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
self.updateWindowFrame() let target = self.targetFrame()
guard let window else { return } guard let window else { return }
if !self.model.isVisible { if !self.model.isVisible {
self.model.isVisible = true self.model.isVisible = true
let start = target.offsetBy(dx: 0, dy: -6)
window.setFrame(start, display: true)
window.alphaValue = 0 window.alphaValue = 0
window.orderFrontRegardless() window.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18 context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 1 window.animator().alphaValue = 1
} }
} else { } else {
self.updateWindowFrame(animate: true)
window.orderFrontRegardless() window.orderFrontRegardless()
} }
} }
@ -132,16 +149,33 @@ final class VoiceWakeOverlayController: ObservableObject {
self.window = panel self.window = panel
} }
private func updateWindowFrame() { private func targetFrame() -> NSRect {
guard let screen = NSScreen.main, let window, let host = self.hostingView else { return } guard let screen = NSScreen.main, let host = self.hostingView else {
return .zero
}
host.layoutSubtreeIfNeeded()
let fit = host.fittingSize let fit = host.fittingSize
let height = max(42, min(fit.height, 140)) let height = max(42, min(fit.height, 180))
let size = NSSize(width: self.width, height: height) let size = NSSize(width: self.width, height: height)
let visible = screen.visibleFrame let visible = screen.visibleFrame
let origin = CGPoint( let origin = CGPoint(
x: visible.maxX - size.width - self.padding, x: visible.maxX - size.width - self.padding,
y: visible.maxY - size.height - self.padding) y: visible.maxY - size.height - self.padding)
window.setFrame(NSRect(origin: origin, size: size), display: true, animate: false) return NSRect(origin: origin, size: size)
}
private func updateWindowFrame(animate: Bool = false) {
guard let window else { return }
let frame = self.targetFrame()
if animate {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(frame, display: true)
}
} else {
window.setFrame(frame, display: true)
}
} }
private func scheduleAutoSend() { private func scheduleAutoSend() {
@ -176,15 +210,25 @@ private struct VoiceWakeOverlayView: View {
Button { Button {
self.controller.sendNow() self.controller.sendNow()
} label: { } label: {
Image(systemName: "paperplane.fill") let sending = self.controller.model.isSending
.imageScale(.small) ZStack {
.padding(.vertical, 6) Image(systemName: "paperplane.fill")
.padding(.horizontal, 10) .opacity(sending ? 0 : 1)
.background(Color.accentColor.opacity(0.12)) .scaleEffect(sending ? 0.5 : 1)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.opacity(sending ? 1 : 0)
.scaleEffect(sending ? 1.05 : 0.8)
}
.imageScale(.small)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.accentColor.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.animation(.spring(response: 0.35, dampingFraction: 0.78), value: sending)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(!self.controller.model.forwardEnabled) .disabled(!self.controller.model.forwardEnabled || self.controller.model.isSending)
.keyboardShortcut(.return, modifiers: [.command]) .keyboardShortcut(.return, modifiers: [.command])
} }
.padding(.vertical, 8) .padding(.vertical, 8)