refactor: split voice wake overlay
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import OSLog
|
||||
import QuartzCore
|
||||
import SwiftUI
|
||||
|
||||
/// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar.
|
||||
@@ -10,12 +9,12 @@ import SwiftUI
|
||||
final class VoiceWakeOverlayController {
|
||||
static let shared = VoiceWakeOverlayController()
|
||||
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay")
|
||||
private let enableUI: Bool
|
||||
let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay")
|
||||
let enableUI: Bool
|
||||
|
||||
/// Keep the voice wake overlay above any other Clawdis windows, but below the system’s pop-up menus.
|
||||
/// (Menu bar menus typically live at `.popUpMenu`.)
|
||||
private static let preferredWindowLevel = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4)
|
||||
static let preferredWindowLevel = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4)
|
||||
|
||||
enum Source: String { case wakeWord, pushToTalk }
|
||||
|
||||
@@ -34,872 +33,29 @@ final class VoiceWakeOverlayController {
|
||||
var level: Double = 0 // normalized 0...1 speech level for UI
|
||||
}
|
||||
|
||||
private var window: NSPanel?
|
||||
private var hostingView: NSHostingView<VoiceWakeOverlayView>?
|
||||
private var autoSendTask: Task<Void, Never>?
|
||||
private var autoSendToken: UUID?
|
||||
private var activeToken: UUID?
|
||||
private var activeSource: Source?
|
||||
private var lastLevelUpdate: TimeInterval = 0
|
||||
var window: NSPanel?
|
||||
var hostingView: NSHostingView<VoiceWakeOverlayView>?
|
||||
var autoSendTask: Task<Void, Never>?
|
||||
var autoSendToken: UUID?
|
||||
var activeToken: UUID?
|
||||
var activeSource: Source?
|
||||
var lastLevelUpdate: TimeInterval = 0
|
||||
|
||||
private let width: CGFloat = 360
|
||||
private let padding: CGFloat = 10
|
||||
private let buttonWidth: CGFloat = 36
|
||||
private let spacing: CGFloat = 8
|
||||
private let verticalPadding: CGFloat = 8
|
||||
private let maxHeight: CGFloat = 400
|
||||
private let minHeight: CGFloat = 48
|
||||
let width: CGFloat = 360
|
||||
let padding: CGFloat = 10
|
||||
let buttonWidth: CGFloat = 36
|
||||
let spacing: CGFloat = 8
|
||||
let verticalPadding: CGFloat = 8
|
||||
let maxHeight: CGFloat = 400
|
||||
let minHeight: CGFloat = 48
|
||||
let closeOverflow: CGFloat = 10
|
||||
private let levelUpdateInterval: TimeInterval = 1.0 / 12.0
|
||||
let levelUpdateInterval: TimeInterval = 1.0 / 12.0
|
||||
|
||||
enum DismissReason { case explicit, empty }
|
||||
enum SendOutcome { case sent, empty }
|
||||
enum GuardOutcome { case accept, dropMismatch, dropNoActive }
|
||||
|
||||
init(enableUI: Bool = true) {
|
||||
self.enableUI = enableUI
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func startSession(
|
||||
token: UUID = UUID(),
|
||||
source: Source,
|
||||
transcript: String,
|
||||
attributed: NSAttributedString? = nil,
|
||||
forwardEnabled: Bool = false,
|
||||
isFinal: Bool = false) -> UUID
|
||||
{
|
||||
let message = """
|
||||
overlay session_start source=\(source.rawValue) \
|
||||
len=\(transcript.count)
|
||||
"""
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
self.activeToken = token
|
||||
self.activeSource = source
|
||||
self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil
|
||||
self.model.text = transcript
|
||||
self.model.isFinal = isFinal
|
||||
self.model.forwardEnabled = forwardEnabled
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
|
||||
self.model.level = 0
|
||||
self.lastLevelUpdate = 0
|
||||
self.present()
|
||||
self.updateWindowFrame(animate: true)
|
||||
return token
|
||||
}
|
||||
|
||||
func snapshot() -> (token: UUID?, source: Source?, text: String, isVisible: Bool) {
|
||||
(self.activeToken, self.activeSource, self.model.text, self.model.isVisible)
|
||||
}
|
||||
|
||||
func updatePartial(token: UUID, transcript: String, attributed: NSAttributedString? = nil) {
|
||||
guard self.guardToken(token, context: "partial") else { return }
|
||||
guard !self.model.isFinal else { return }
|
||||
let message = """
|
||||
overlay partial token=\(token.uuidString) \
|
||||
len=\(transcript.count)
|
||||
"""
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil
|
||||
self.model.text = transcript
|
||||
self.model.isFinal = false
|
||||
self.model.forwardEnabled = false
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
|
||||
self.model.level = 0
|
||||
self.present()
|
||||
self.updateWindowFrame(animate: true)
|
||||
}
|
||||
|
||||
func presentFinal(
|
||||
token: UUID,
|
||||
transcript: String,
|
||||
autoSendAfter delay: TimeInterval?,
|
||||
sendChime: VoiceWakeChime = .none,
|
||||
attributed: NSAttributedString? = nil)
|
||||
{
|
||||
guard self.guardToken(token, context: "final") else { return }
|
||||
let message = """
|
||||
overlay presentFinal token=\(token.uuidString) \
|
||||
len=\(transcript.count) \
|
||||
autoSendAfter=\(delay ?? -1) \
|
||||
forwardEnabled=\(!transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
"""
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
self.autoSendTask?.cancel()
|
||||
self.autoSendToken = token
|
||||
self.model.text = transcript
|
||||
self.model.isFinal = true
|
||||
self.model.forwardEnabled = !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
|
||||
self.model.level = 0
|
||||
self.present()
|
||||
if let delay {
|
||||
if delay <= 0 {
|
||||
self.logger.log(level: .info, "overlay autoSend immediate token=\(token.uuidString)")
|
||||
VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendImmediate")
|
||||
} else {
|
||||
self.scheduleAutoSend(token: token, after: delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func userBeganEditing() {
|
||||
self.autoSendTask?.cancel()
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = true
|
||||
}
|
||||
|
||||
func cancelEditingAndDismiss() {
|
||||
self.autoSendTask?.cancel()
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
self.dismiss(reason: .explicit)
|
||||
}
|
||||
|
||||
func endEditing() {
|
||||
self.model.isEditing = false
|
||||
}
|
||||
|
||||
func updateText(_ text: String) {
|
||||
self.model.text = text
|
||||
self.model.isSending = false
|
||||
self.model.attributed = self.makeAttributed(from: text)
|
||||
self.updateWindowFrame(animate: true)
|
||||
}
|
||||
|
||||
/// UI-only path: show sending state and dismiss; actual forwarding is handled by the coordinator.
|
||||
func beginSendUI(token: UUID, sendChime: VoiceWakeChime = .none) {
|
||||
guard self.guardToken(token, context: "beginSendUI") else { return }
|
||||
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
||||
let message = """
|
||||
overlay beginSendUI token=\(token.uuidString) \
|
||||
isSending=\(self.model.isSending) \
|
||||
forwardEnabled=\(self.model.forwardEnabled) \
|
||||
textLen=\(self.model.text.count)
|
||||
"""
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
if self.model.isSending { return }
|
||||
self.model.isEditing = false
|
||||
|
||||
if sendChime != .none {
|
||||
let message = "overlay beginSendUI playing sendChime=\(String(describing: sendChime))"
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send")
|
||||
}
|
||||
|
||||
self.model.isSending = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"overlay beginSendUI dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")")
|
||||
self.dismiss(token: token, reason: .explicit, outcome: .sent)
|
||||
}
|
||||
}
|
||||
|
||||
func requestSend(token: UUID? = nil, reason: String = "overlay_request") {
|
||||
guard self.guardToken(token, context: "requestSend") else { return }
|
||||
guard let active = token ?? self.activeToken else { return }
|
||||
VoiceSessionCoordinator.shared.sendNow(token: active, reason: reason)
|
||||
}
|
||||
|
||||
func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) {
|
||||
guard self.guardToken(token, context: "dismiss") else { return }
|
||||
let message = """
|
||||
overlay dismiss token=\(self.activeToken?.uuidString ?? "nil") \
|
||||
reason=\(String(describing: reason)) \
|
||||
outcome=\(String(describing: outcome)) \
|
||||
visible=\(self.model.isVisible) \
|
||||
sending=\(self.model.isSending)
|
||||
"""
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
|
||||
if !self.enableUI {
|
||||
self.model.isVisible = false
|
||||
self.model.level = 0
|
||||
self.lastLevelUpdate = 0
|
||||
self.activeToken = nil
|
||||
self.activeSource = nil
|
||||
return
|
||||
}
|
||||
guard let window else {
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
self.model.isVisible = false
|
||||
self.model.level = 0
|
||||
self.activeToken = nil
|
||||
self.activeSource = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
let target = self.dismissTargetFrame(for: window.frame, reason: reason, outcome: outcome)
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
if let target {
|
||||
window.animator().setFrame(target, display: true)
|
||||
}
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
Task { @MainActor in
|
||||
let dismissedToken = self.activeToken
|
||||
window.orderOut(nil)
|
||||
self.model.isVisible = false
|
||||
self.model.level = 0
|
||||
self.lastLevelUpdate = 0
|
||||
self.activeToken = nil
|
||||
self.activeSource = nil
|
||||
if outcome == .empty {
|
||||
AppStateStore.shared.blinkOnce()
|
||||
} else if outcome == .sent {
|
||||
AppStateStore.shared.celebrateSend()
|
||||
}
|
||||
AppStateStore.shared.stopVoiceEars()
|
||||
VoiceSessionCoordinator.shared.overlayDidDismiss(token: dismissedToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateLevel(token: UUID, _ level: Double) {
|
||||
guard self.guardToken(token, context: "level") else { return }
|
||||
guard self.model.isVisible else { return }
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
if level != 0, now - self.lastLevelUpdate < self.levelUpdateInterval {
|
||||
return
|
||||
}
|
||||
self.lastLevelUpdate = now
|
||||
self.model.level = max(0, min(1, level))
|
||||
}
|
||||
|
||||
enum DismissReason { case explicit, empty }
|
||||
enum SendOutcome { case sent, empty }
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func guardToken(_ token: UUID?, context: String) -> Bool {
|
||||
switch Self.evaluateToken(active: self.activeToken, incoming: token) {
|
||||
case .accept:
|
||||
return true
|
||||
case .dropMismatch:
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"""
|
||||
overlay drop \(context, privacy: .public) token_mismatch \
|
||||
active=\(self.activeToken?.uuidString ?? "nil", privacy: .public) \
|
||||
got=\(token?.uuidString ?? "nil", privacy: .public)
|
||||
""")
|
||||
return false
|
||||
case .dropNoActive:
|
||||
self.logger.log(level: .info, "overlay drop \(context, privacy: .public) no_active")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum GuardOutcome { case accept, dropMismatch, dropNoActive }
|
||||
|
||||
nonisolated static func evaluateToken(active: UUID?, incoming: UUID?) -> GuardOutcome {
|
||||
guard let active else { return .dropNoActive }
|
||||
if let incoming, incoming != active { return .dropMismatch }
|
||||
return .accept
|
||||
}
|
||||
|
||||
private func present() {
|
||||
if !self.enableUI || ProcessInfo.processInfo.isRunningTests {
|
||||
if !self.model.isVisible {
|
||||
self.model.isVisible = true
|
||||
}
|
||||
return
|
||||
}
|
||||
self.ensureWindow()
|
||||
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
|
||||
let target = self.targetFrame()
|
||||
|
||||
guard let window else { return }
|
||||
if !self.model.isVisible {
|
||||
self.model.isVisible = true
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"overlay present windowShown textLen=\(self.model.text.count, privacy: .public)")
|
||||
// Keep the status item in “listening” mode until we explicitly dismiss the overlay.
|
||||
AppStateStore.shared.triggerVoiceEars(ttl: nil)
|
||||
let start = target.offsetBy(dx: 0, dy: -6)
|
||||
window.setFrame(start, display: true)
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
} else {
|
||||
self.updateWindowFrame(animate: true)
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureWindow() {
|
||||
if self.window != nil { return }
|
||||
let borderPad = self.closeOverflow
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: self.width + borderPad * 2, height: 60 + borderPad * 2),
|
||||
styleMask: [.nonactivatingPanel, .borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = false
|
||||
panel.level = Self.preferredWindowLevel
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.isMovable = false
|
||||
panel.isFloatingPanel = true
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.titleVisibility = .hidden
|
||||
panel.titlebarAppearsTransparent = true
|
||||
|
||||
let host = NSHostingView(rootView: VoiceWakeOverlayView(controller: self))
|
||||
host.translatesAutoresizingMaskIntoConstraints = false
|
||||
panel.contentView = host
|
||||
self.hostingView = host
|
||||
self.window = panel
|
||||
}
|
||||
|
||||
/// Reassert window ordering when other panels are shown.
|
||||
func bringToFrontIfVisible() {
|
||||
guard self.model.isVisible, let window = self.window else { return }
|
||||
window.level = Self.preferredWindowLevel
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
|
||||
private func targetFrame() -> NSRect {
|
||||
guard let screen = NSScreen.main else { return .zero }
|
||||
let height = self.measuredHeight()
|
||||
let size = NSSize(width: self.width + self.closeOverflow * 2, height: height + self.closeOverflow * 2)
|
||||
let visible = screen.visibleFrame
|
||||
let origin = CGPoint(
|
||||
x: visible.maxX - size.width,
|
||||
y: visible.maxY - size.height)
|
||||
return NSRect(origin: origin, size: size)
|
||||
}
|
||||
|
||||
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 measuredHeight() -> CGFloat {
|
||||
let attributed = self.model.attributed.length > 0 ? self.model.attributed : self
|
||||
.makeAttributed(from: self.model.text)
|
||||
let maxWidth = self.width - (self.padding * 2) - self.spacing - self.buttonWidth
|
||||
|
||||
let textInset = NSSize(width: 2, height: 6)
|
||||
let lineFragmentPadding: CGFloat = 0
|
||||
let containerWidth = max(1, maxWidth - (textInset.width * 2) - (lineFragmentPadding * 2))
|
||||
|
||||
let storage = NSTextStorage(attributedString: attributed)
|
||||
let container = NSTextContainer(containerSize: CGSize(width: containerWidth, height: .greatestFiniteMagnitude))
|
||||
container.lineFragmentPadding = lineFragmentPadding
|
||||
container.lineBreakMode = .byWordWrapping
|
||||
|
||||
let layout = NSLayoutManager()
|
||||
layout.addTextContainer(container)
|
||||
storage.addLayoutManager(layout)
|
||||
|
||||
_ = layout.glyphRange(for: container)
|
||||
let used = layout.usedRect(for: container)
|
||||
|
||||
let contentHeight = ceil(used.height + (textInset.height * 2))
|
||||
let total = contentHeight + self.verticalPadding * 2
|
||||
self.model.isOverflowing = total > self.maxHeight
|
||||
return max(self.minHeight, min(total, self.maxHeight))
|
||||
}
|
||||
|
||||
private func dismissTargetFrame(for frame: NSRect, reason: DismissReason, outcome: SendOutcome) -> NSRect? {
|
||||
switch (reason, outcome) {
|
||||
case (.empty, _):
|
||||
let scale: CGFloat = 0.95
|
||||
let newSize = NSSize(width: frame.size.width * scale, height: frame.size.height * scale)
|
||||
let dx = (frame.size.width - newSize.width) / 2
|
||||
let dy = (frame.size.height - newSize.height) / 2
|
||||
return NSRect(x: frame.origin.x + dx, y: frame.origin.y + dy, width: newSize.width, height: newSize.height)
|
||||
case (.explicit, .sent):
|
||||
return frame.offsetBy(dx: 8, dy: 6)
|
||||
default:
|
||||
return frame
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleAutoSend(token: UUID, after delay: TimeInterval) {
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"""
|
||||
overlay scheduleAutoSend token=\(token.uuidString) \
|
||||
after=\(delay)
|
||||
""")
|
||||
self.autoSendTask?.cancel()
|
||||
self.autoSendToken = token
|
||||
self.autoSendTask = Task<Void, Never> { [weak self, token] in
|
||||
let nanos = UInt64(max(0, delay) * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
guard self.guardToken(token, context: "autoSend") else { return }
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"overlay autoSend firing token=\(token.uuidString, privacy: .public)")
|
||||
VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendDelay")
|
||||
self.autoSendTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeAttributed(from text: String) -> NSAttributedString {
|
||||
NSAttributedString(
|
||||
string: text,
|
||||
attributes: [
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceWakeOverlayView: View {
|
||||
var controller: VoiceWakeOverlayController
|
||||
@FocusState private var textFocused: Bool
|
||||
@State private var isHovering: Bool = false
|
||||
@State private var closeHovering: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if self.controller.model.isEditing {
|
||||
TranscriptTextView(
|
||||
text: Binding(
|
||||
get: { self.controller.model.text },
|
||||
set: { self.controller.updateText($0) }),
|
||||
attributed: self.controller.model.attributed,
|
||||
isFinal: self.controller.model.isFinal,
|
||||
isOverflowing: self.controller.model.isOverflowing,
|
||||
onBeginEditing: {
|
||||
self.controller.userBeganEditing()
|
||||
},
|
||||
onEscape: {
|
||||
self.controller.cancelEditingAndDismiss()
|
||||
},
|
||||
onEndEditing: {
|
||||
self.controller.endEditing()
|
||||
},
|
||||
onSend: {
|
||||
self.controller.requestSend()
|
||||
})
|
||||
.focused(self.$textFocused)
|
||||
.frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading)
|
||||
.id("editing")
|
||||
} else {
|
||||
VibrantLabelView(
|
||||
attributed: self.controller.model.attributed,
|
||||
onTap: {
|
||||
self.controller.userBeganEditing()
|
||||
self.textFocused = true
|
||||
})
|
||||
.frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading)
|
||||
.focusable(false)
|
||||
.id("display")
|
||||
}
|
||||
|
||||
Button {
|
||||
self.controller.requestSend()
|
||||
} label: {
|
||||
let sending = self.controller.model.isSending
|
||||
let level = self.controller.model.level
|
||||
ZStack {
|
||||
GeometryReader { geo in
|
||||
let width = geo.size.width
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.12))
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.25))
|
||||
.frame(width: width * max(0, min(1, level)), alignment: .leading)
|
||||
.animation(.easeOut(duration: 0.08), value: level)
|
||||
}
|
||||
.frame(height: 28)
|
||||
|
||||
ZStack {
|
||||
Image(systemName: "paperplane.fill")
|
||||
.opacity(sending ? 0 : 1)
|
||||
.scaleEffect(sending ? 0.5 : 1)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.opacity(sending ? 1 : 0)
|
||||
.scaleEffect(sending ? 1.05 : 0.8)
|
||||
}
|
||||
.imageScale(.small)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.frame(width: 32, height: 28)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.78), value: sending)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!self.controller.model.forwardEnabled || self.controller.model.isSending)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background {
|
||||
OverlayBackground()
|
||||
.equatable()
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.22), radius: 14, x: 0, y: -2)
|
||||
.onHover { self.isHovering = $0 }
|
||||
|
||||
// Close button rendered above and outside the clipped bubble
|
||||
CloseButtonOverlay(
|
||||
isVisible: self.controller.model.isEditing || self.isHovering || self.closeHovering,
|
||||
onHover: { self.closeHovering = $0 },
|
||||
onClose: { self.controller.cancelEditingAndDismiss() })
|
||||
}
|
||||
.padding(.top, self.controller.closeOverflow)
|
||||
.padding(.leading, self.controller.closeOverflow)
|
||||
.padding(.trailing, self.controller.closeOverflow)
|
||||
.padding(.bottom, self.controller.closeOverflow)
|
||||
.onAppear {
|
||||
self.updateFocusState(visible: self.controller.model.isVisible, editing: self.controller.model.isEditing)
|
||||
}
|
||||
.onChange(of: self.controller.model.isVisible) { _, visible in
|
||||
self.updateFocusState(visible: visible, editing: self.controller.model.isEditing)
|
||||
}
|
||||
.onChange(of: self.controller.model.isEditing) { _, editing in
|
||||
self.updateFocusState(visible: self.controller.model.isVisible, editing: editing)
|
||||
}
|
||||
.onChange(of: self.controller.model.attributed) { _, _ in
|
||||
self.controller.updateWindowFrame(animate: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFocusState(visible: Bool, editing: Bool) {
|
||||
let shouldFocus = visible && editing
|
||||
guard self.textFocused != shouldFocus else { return }
|
||||
self.textFocused = shouldFocus
|
||||
}
|
||||
}
|
||||
|
||||
private struct OverlayBackground: View {
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||
.clipShape(shape)
|
||||
.overlay(shape.strokeBorder(Color.white.opacity(0.16), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
extension OverlayBackground: @MainActor Equatable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool { true }
|
||||
}
|
||||
|
||||
struct TranscriptTextView: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
var attributed: NSAttributedString
|
||||
var isFinal: Bool
|
||||
var isOverflowing: Bool
|
||||
var onBeginEditing: () -> Void
|
||||
var onEscape: () -> Void
|
||||
var onEndEditing: () -> Void
|
||||
var onSend: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let textView = TranscriptNSTextView()
|
||||
textView.delegate = context.coordinator
|
||||
textView.drawsBackground = false
|
||||
textView.isRichText = true
|
||||
textView.isAutomaticQuoteSubstitutionEnabled = false
|
||||
textView.isAutomaticTextReplacementEnabled = false
|
||||
textView.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
textView.textContainer?.lineBreakMode = .byWordWrapping
|
||||
textView.textContainer?.lineFragmentPadding = 0
|
||||
textView.textContainerInset = NSSize(width: 2, height: 6)
|
||||
|
||||
textView.minSize = .zero
|
||||
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.isVerticallyResizable = true
|
||||
textView.autoresizingMask = [.width]
|
||||
|
||||
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
|
||||
textView.textStorage?.setAttributedString(self.attributed)
|
||||
textView.typingAttributes = [
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||
]
|
||||
textView.focusRingType = .none
|
||||
textView.onSend = { [weak textView] in
|
||||
textView?.window?.makeFirstResponder(nil)
|
||||
self.onSend()
|
||||
}
|
||||
textView.onBeginEditing = self.onBeginEditing
|
||||
textView.onEscape = self.onEscape
|
||||
textView.onEndEditing = self.onEndEditing
|
||||
|
||||
let scroll = NSScrollView()
|
||||
scroll.drawsBackground = false
|
||||
scroll.borderType = .noBorder
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.scrollerStyle = .overlay
|
||||
scroll.hasHorizontalScroller = false
|
||||
scroll.documentView = textView
|
||||
return scroll
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
guard let textView = scrollView.documentView as? TranscriptNSTextView else { return }
|
||||
let isEditing = scrollView.window?.firstResponder == textView
|
||||
if isEditing {
|
||||
return
|
||||
}
|
||||
|
||||
if !textView.attributedString().isEqual(to: self.attributed) {
|
||||
context.coordinator.isProgrammaticUpdate = true
|
||||
defer { context.coordinator.isProgrammaticUpdate = false }
|
||||
textView.textStorage?.setAttributedString(self.attributed)
|
||||
}
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
var parent: TranscriptTextView
|
||||
var isProgrammaticUpdate = false
|
||||
|
||||
init(_ parent: TranscriptTextView) { self.parent = parent }
|
||||
|
||||
func textDidBeginEditing(_ notification: Notification) {
|
||||
self.parent.onBeginEditing()
|
||||
}
|
||||
|
||||
func textDidEndEditing(_ notification: Notification) {
|
||||
self.parent.onEndEditing()
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard !self.isProgrammaticUpdate else { return }
|
||||
guard let view = notification.object as? NSTextView else { return }
|
||||
guard view.window?.firstResponder === view else { return }
|
||||
self.parent.text = view.string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vibrant display label
|
||||
|
||||
struct VibrantLabelView: NSViewRepresentable {
|
||||
var attributed: NSAttributedString
|
||||
var onTap: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let label = NSTextField(labelWithAttributedString: self.attributed)
|
||||
label.isEditable = false
|
||||
label.isBordered = false
|
||||
label.drawsBackground = false
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.maximumNumberOfLines = 0
|
||||
label.usesSingleLineMode = false
|
||||
label.cell?.wraps = true
|
||||
label.cell?.isScrollable = false
|
||||
label.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
label.setContentHuggingPriority(.required, for: .vertical)
|
||||
label.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
label.textColor = .labelColor
|
||||
|
||||
let container = ClickCatcher(onTap: onTap)
|
||||
container.addSubview(label)
|
||||
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
label.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
label.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
label.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
return container
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
guard let container = nsView as? ClickCatcher,
|
||||
let label = container.subviews.first as? NSTextField else { return }
|
||||
label.attributedStringValue = self.attributed.strippingForegroundColor()
|
||||
label.textColor = .labelColor
|
||||
}
|
||||
}
|
||||
|
||||
private final class ClickCatcher: NSView {
|
||||
let onTap: () -> Void
|
||||
init(onTap: @escaping () -> Void) {
|
||||
self.onTap = onTap
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
super.mouseDown(with: event)
|
||||
self.onTap()
|
||||
}
|
||||
}
|
||||
|
||||
struct CloseHoverButton: View {
|
||||
var onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(Color.white.opacity(0.85))
|
||||
.frame(width: 22, height: 22)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.black.opacity(0.35), radius: 6, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.contentShape(Circle())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
struct CloseButtonOverlay: View {
|
||||
var isVisible: Bool
|
||||
var onHover: (Bool) -> Void
|
||||
var onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if self.isVisible {
|
||||
Button(action: self.onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(Color.white.opacity(0.9))
|
||||
.frame(width: 22, height: 22)
|
||||
.background(Color.black.opacity(0.4))
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 10, x: 0, y: 3)
|
||||
.shadow(color: Color.black.opacity(0.2), radius: 2, x: 0, y: 0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.contentShape(Circle())
|
||||
.padding(6)
|
||||
.onHover { self.onHover($0) }
|
||||
.offset(x: -9, y: -9)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(self.isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
private final class TranscriptNSTextView: NSTextView {
|
||||
var onSend: (() -> Void)?
|
||||
var onBeginEditing: (() -> Void)?
|
||||
var onEndEditing: (() -> Void)?
|
||||
var onEscape: (() -> Void)?
|
||||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
self.onBeginEditing?()
|
||||
return super.becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
let result = super.resignFirstResponder()
|
||||
self.onEndEditing?()
|
||||
return result
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
let isReturn = event.keyCode == 36
|
||||
let isEscape = event.keyCode == 53
|
||||
if isEscape {
|
||||
self.onEscape?()
|
||||
return
|
||||
}
|
||||
if isReturn, event.modifierFlags.contains(.command) {
|
||||
self.onSend?()
|
||||
return
|
||||
}
|
||||
if isReturn {
|
||||
if event.modifierFlags.contains(.shift) {
|
||||
super.insertNewline(nil)
|
||||
return
|
||||
}
|
||||
self.onSend?()
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
extension VoiceWakeOverlayController {
|
||||
static func exerciseForTesting() async {
|
||||
let controller = VoiceWakeOverlayController(enableUI: false)
|
||||
let token = controller.startSession(
|
||||
source: .wakeWord,
|
||||
transcript: "Hello",
|
||||
attributed: nil,
|
||||
forwardEnabled: true,
|
||||
isFinal: false)
|
||||
|
||||
controller.updatePartial(token: token, transcript: "Hello world")
|
||||
controller.presentFinal(token: token, transcript: "Final", autoSendAfter: nil)
|
||||
controller.userBeganEditing()
|
||||
controller.endEditing()
|
||||
controller.updateText("Edited text")
|
||||
|
||||
_ = controller.makeAttributed(from: "Attributed")
|
||||
_ = controller.targetFrame()
|
||||
_ = controller.measuredHeight()
|
||||
_ = controller.dismissTargetFrame(
|
||||
for: NSRect(x: 0, y: 0, width: 120, height: 60),
|
||||
reason: .empty,
|
||||
outcome: .empty)
|
||||
_ = controller.dismissTargetFrame(
|
||||
for: NSRect(x: 0, y: 0, width: 120, height: 60),
|
||||
reason: .explicit,
|
||||
outcome: .sent)
|
||||
_ = controller.dismissTargetFrame(
|
||||
for: NSRect(x: 0, y: 0, width: 120, height: 60),
|
||||
reason: .explicit,
|
||||
outcome: .empty)
|
||||
|
||||
controller.beginSendUI(token: token, sendChime: .none)
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
|
||||
controller.scheduleAutoSend(token: token, after: 10)
|
||||
controller.autoSendTask?.cancel()
|
||||
controller.autoSendTask = nil
|
||||
controller.autoSendToken = nil
|
||||
|
||||
controller.dismiss(token: token, reason: .explicit, outcome: .sent)
|
||||
controller.bringToFrontIfVisible()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
|
||||
extension VoiceWakeOverlayController {
|
||||
@discardableResult
|
||||
func startSession(
|
||||
token: UUID = UUID(),
|
||||
source: Source,
|
||||
transcript: String,
|
||||
attributed: NSAttributedString? = nil,
|
||||
forwardEnabled: Bool = false,
|
||||
isFinal: Bool = false) -> UUID
|
||||
{
|
||||
let message = """
|
||||
overlay session_start source=\(source.rawValue) \
|
||||
len=\(transcript.count)
|
||||
"""
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
self.activeToken = token
|
||||
self.activeSource = source
|
||||
self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil
|
||||
self.model.text = transcript
|
||||
self.model.isFinal = isFinal
|
||||
self.model.forwardEnabled = forwardEnabled
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
|
||||
self.model.level = 0
|
||||
self.lastLevelUpdate = 0
|
||||
self.present()
|
||||
self.updateWindowFrame(animate: true)
|
||||
return token
|
||||
}
|
||||
|
||||
func snapshot() -> (token: UUID?, source: Source?, text: String, isVisible: Bool) {
|
||||
(self.activeToken, self.activeSource, self.model.text, self.model.isVisible)
|
||||
}
|
||||
|
||||
func updatePartial(token: UUID, transcript: String, attributed: NSAttributedString? = nil) {
|
||||
guard self.guardToken(token, context: "partial") else { return }
|
||||
guard !self.model.isFinal else { return }
|
||||
let message = """
|
||||
overlay partial token=\(token.uuidString) \
|
||||
len=\(transcript.count)
|
||||
"""
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
self.autoSendTask?.cancel(); self.autoSendTask = nil; self.autoSendToken = nil
|
||||
self.model.text = transcript
|
||||
self.model.isFinal = false
|
||||
self.model.forwardEnabled = false
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
|
||||
self.model.level = 0
|
||||
self.present()
|
||||
self.updateWindowFrame(animate: true)
|
||||
}
|
||||
|
||||
func presentFinal(
|
||||
token: UUID,
|
||||
transcript: String,
|
||||
autoSendAfter delay: TimeInterval?,
|
||||
sendChime: VoiceWakeChime = .none,
|
||||
attributed: NSAttributedString? = nil)
|
||||
{
|
||||
guard self.guardToken(token, context: "final") else { return }
|
||||
let message = """
|
||||
overlay presentFinal token=\(token.uuidString) \
|
||||
len=\(transcript.count) \
|
||||
autoSendAfter=\(delay ?? -1) \
|
||||
forwardEnabled=\(!transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
"""
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
self.autoSendTask?.cancel()
|
||||
self.autoSendToken = token
|
||||
self.model.text = transcript
|
||||
self.model.isFinal = true
|
||||
self.model.forwardEnabled = !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
self.model.attributed = attributed ?? self.makeAttributed(from: transcript)
|
||||
self.model.level = 0
|
||||
self.present()
|
||||
if let delay {
|
||||
if delay <= 0 {
|
||||
self.logger.log(level: .info, "overlay autoSend immediate token=\(token.uuidString)")
|
||||
VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendImmediate")
|
||||
} else {
|
||||
self.scheduleAutoSend(token: token, after: delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func userBeganEditing() {
|
||||
self.autoSendTask?.cancel()
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = true
|
||||
}
|
||||
|
||||
func cancelEditingAndDismiss() {
|
||||
self.autoSendTask?.cancel()
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
self.dismiss(reason: .explicit)
|
||||
}
|
||||
|
||||
func endEditing() {
|
||||
self.model.isEditing = false
|
||||
}
|
||||
|
||||
func updateText(_ text: String) {
|
||||
self.model.text = text
|
||||
self.model.isSending = false
|
||||
self.model.attributed = self.makeAttributed(from: text)
|
||||
self.updateWindowFrame(animate: true)
|
||||
}
|
||||
|
||||
/// UI-only path: show sending state and dismiss; actual forwarding is handled by the coordinator.
|
||||
func beginSendUI(token: UUID, sendChime: VoiceWakeChime = .none) {
|
||||
guard self.guardToken(token, context: "beginSendUI") else { return }
|
||||
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
||||
let message = """
|
||||
overlay beginSendUI token=\(token.uuidString) \
|
||||
isSending=\(self.model.isSending) \
|
||||
forwardEnabled=\(self.model.forwardEnabled) \
|
||||
textLen=\(self.model.text.count)
|
||||
"""
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
if self.model.isSending { return }
|
||||
self.model.isEditing = false
|
||||
|
||||
if sendChime != .none {
|
||||
let message = "overlay beginSendUI playing sendChime=\(String(describing: sendChime))"
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
VoiceWakeChimePlayer.play(sendChime, reason: "overlay.send")
|
||||
}
|
||||
|
||||
self.model.isSending = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.28) {
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"overlay beginSendUI dismiss ticking token=\(self.activeToken?.uuidString ?? "nil")")
|
||||
self.dismiss(token: token, reason: .explicit, outcome: .sent)
|
||||
}
|
||||
}
|
||||
|
||||
func requestSend(token: UUID? = nil, reason: String = "overlay_request") {
|
||||
guard self.guardToken(token, context: "requestSend") else { return }
|
||||
guard let active = token ?? self.activeToken else { return }
|
||||
VoiceSessionCoordinator.shared.sendNow(token: active, reason: reason)
|
||||
}
|
||||
|
||||
func dismiss(token: UUID? = nil, reason: DismissReason = .explicit, outcome: SendOutcome = .empty) {
|
||||
guard self.guardToken(token, context: "dismiss") else { return }
|
||||
let message = """
|
||||
overlay dismiss token=\(self.activeToken?.uuidString ?? "nil") \
|
||||
reason=\(String(describing: reason)) \
|
||||
outcome=\(String(describing: outcome)) \
|
||||
visible=\(self.model.isVisible) \
|
||||
sending=\(self.model.isSending)
|
||||
"""
|
||||
self.logger.log(level: .info, "\(message)")
|
||||
self.autoSendTask?.cancel(); self.autoSendToken = nil
|
||||
self.model.isSending = false
|
||||
self.model.isEditing = false
|
||||
|
||||
if !self.enableUI {
|
||||
self.model.isVisible = false
|
||||
self.model.level = 0
|
||||
self.lastLevelUpdate = 0
|
||||
self.activeToken = nil
|
||||
self.activeSource = nil
|
||||
return
|
||||
}
|
||||
guard let window else {
|
||||
if ProcessInfo.processInfo.isRunningTests {
|
||||
self.model.isVisible = false
|
||||
self.model.level = 0
|
||||
self.activeToken = nil
|
||||
self.activeSource = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
let target = self.dismissTargetFrame(for: window.frame, reason: reason, outcome: outcome)
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
if let target {
|
||||
window.animator().setFrame(target, display: true)
|
||||
}
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
Task { @MainActor in
|
||||
let dismissedToken = self.activeToken
|
||||
window.orderOut(nil)
|
||||
self.model.isVisible = false
|
||||
self.model.level = 0
|
||||
self.lastLevelUpdate = 0
|
||||
self.activeToken = nil
|
||||
self.activeSource = nil
|
||||
if outcome == .empty {
|
||||
AppStateStore.shared.blinkOnce()
|
||||
} else if outcome == .sent {
|
||||
AppStateStore.shared.celebrateSend()
|
||||
}
|
||||
AppStateStore.shared.stopVoiceEars()
|
||||
VoiceSessionCoordinator.shared.overlayDidDismiss(token: dismissedToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateLevel(token: UUID, _ level: Double) {
|
||||
guard self.guardToken(token, context: "level") else { return }
|
||||
guard self.model.isVisible else { return }
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
if level != 0, now - self.lastLevelUpdate < self.levelUpdateInterval {
|
||||
return
|
||||
}
|
||||
self.lastLevelUpdate = now
|
||||
self.model.level = max(0, min(1, level))
|
||||
}
|
||||
|
||||
private func guardToken(_ token: UUID?, context: String) -> Bool {
|
||||
switch Self.evaluateToken(active: self.activeToken, incoming: token) {
|
||||
case .accept:
|
||||
return true
|
||||
case .dropMismatch:
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"""
|
||||
overlay drop \(context, privacy: .public) token_mismatch \
|
||||
active=\(self.activeToken?.uuidString ?? "nil", privacy: .public) \
|
||||
got=\(token?.uuidString ?? "nil", privacy: .public)
|
||||
""")
|
||||
return false
|
||||
case .dropNoActive:
|
||||
self.logger.log(level: .info, "overlay drop \(context, privacy: .public) no_active")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func evaluateToken(active: UUID?, incoming: UUID?) -> GuardOutcome {
|
||||
guard let active else { return .dropNoActive }
|
||||
if let incoming, incoming != active { return .dropMismatch }
|
||||
return .accept
|
||||
}
|
||||
|
||||
func scheduleAutoSend(token: UUID, after delay: TimeInterval) {
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"""
|
||||
overlay scheduleAutoSend token=\(token.uuidString) \
|
||||
after=\(delay)
|
||||
""")
|
||||
self.autoSendTask?.cancel()
|
||||
self.autoSendToken = token
|
||||
self.autoSendTask = Task<Void, Never> { [weak self, token] in
|
||||
let nanos = UInt64(max(0, delay) * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
guard self.guardToken(token, context: "autoSend") else { return }
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"overlay autoSend firing token=\(token.uuidString, privacy: .public)")
|
||||
VoiceSessionCoordinator.shared.sendNow(token: token, reason: "autoSendDelay")
|
||||
self.autoSendTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeAttributed(from text: String) -> NSAttributedString {
|
||||
NSAttributedString(
|
||||
string: text,
|
||||
attributes: [
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import AppKit
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
extension VoiceWakeOverlayController {
|
||||
static func exerciseForTesting() async {
|
||||
let controller = VoiceWakeOverlayController(enableUI: false)
|
||||
let token = controller.startSession(
|
||||
source: .wakeWord,
|
||||
transcript: "Hello",
|
||||
attributed: nil,
|
||||
forwardEnabled: true,
|
||||
isFinal: false)
|
||||
|
||||
controller.updatePartial(token: token, transcript: "Hello world")
|
||||
controller.presentFinal(token: token, transcript: "Final", autoSendAfter: nil)
|
||||
controller.userBeganEditing()
|
||||
controller.endEditing()
|
||||
controller.updateText("Edited text")
|
||||
|
||||
_ = controller.makeAttributed(from: "Attributed")
|
||||
_ = controller.targetFrame()
|
||||
_ = controller.measuredHeight()
|
||||
_ = controller.dismissTargetFrame(
|
||||
for: NSRect(x: 0, y: 0, width: 120, height: 60),
|
||||
reason: .empty,
|
||||
outcome: .empty)
|
||||
_ = controller.dismissTargetFrame(
|
||||
for: NSRect(x: 0, y: 0, width: 120, height: 60),
|
||||
reason: .explicit,
|
||||
outcome: .sent)
|
||||
_ = controller.dismissTargetFrame(
|
||||
for: NSRect(x: 0, y: 0, width: 120, height: 60),
|
||||
reason: .explicit,
|
||||
outcome: .empty)
|
||||
|
||||
controller.beginSendUI(token: token, sendChime: .none)
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
|
||||
controller.scheduleAutoSend(token: token, after: 10)
|
||||
controller.autoSendTask?.cancel()
|
||||
controller.autoSendTask = nil
|
||||
controller.autoSendToken = nil
|
||||
|
||||
controller.dismiss(token: token, reason: .explicit, outcome: .sent)
|
||||
controller.bringToFrontIfVisible()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,140 @@
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
|
||||
extension VoiceWakeOverlayController {
|
||||
private func present() {
|
||||
if !self.enableUI || ProcessInfo.processInfo.isRunningTests {
|
||||
if !self.model.isVisible {
|
||||
self.model.isVisible = true
|
||||
}
|
||||
return
|
||||
}
|
||||
self.ensureWindow()
|
||||
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
|
||||
let target = self.targetFrame()
|
||||
|
||||
guard let window else { return }
|
||||
if !self.model.isVisible {
|
||||
self.model.isVisible = true
|
||||
self.logger.log(
|
||||
level: .info,
|
||||
"overlay present windowShown textLen=\(self.model.text.count, privacy: .public)")
|
||||
// Keep the status item in “listening” mode until we explicitly dismiss the overlay.
|
||||
AppStateStore.shared.triggerVoiceEars(ttl: nil)
|
||||
let start = target.offsetBy(dx: 0, dy: -6)
|
||||
window.setFrame(start, display: true)
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
} else {
|
||||
self.updateWindowFrame(animate: true)
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureWindow() {
|
||||
if self.window != nil { return }
|
||||
let borderPad = self.closeOverflow
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: self.width + borderPad * 2, height: 60 + borderPad * 2),
|
||||
styleMask: [.nonactivatingPanel, .borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = false
|
||||
panel.level = Self.preferredWindowLevel
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.isMovable = false
|
||||
panel.isFloatingPanel = true
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.titleVisibility = .hidden
|
||||
panel.titlebarAppearsTransparent = true
|
||||
|
||||
let host = NSHostingView(rootView: VoiceWakeOverlayView(controller: self))
|
||||
host.translatesAutoresizingMaskIntoConstraints = false
|
||||
panel.contentView = host
|
||||
self.hostingView = host
|
||||
self.window = panel
|
||||
}
|
||||
|
||||
/// Reassert window ordering when other panels are shown.
|
||||
func bringToFrontIfVisible() {
|
||||
guard self.model.isVisible, let window = self.window else { return }
|
||||
window.level = Self.preferredWindowLevel
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
|
||||
func targetFrame() -> NSRect {
|
||||
guard let screen = NSScreen.main else { return .zero }
|
||||
let height = self.measuredHeight()
|
||||
let size = NSSize(width: self.width + self.closeOverflow * 2, height: height + self.closeOverflow * 2)
|
||||
let visible = screen.visibleFrame
|
||||
let origin = CGPoint(
|
||||
x: visible.maxX - size.width,
|
||||
y: visible.maxY - size.height)
|
||||
return NSRect(origin: origin, size: size)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func measuredHeight() -> CGFloat {
|
||||
let attributed = self.model.attributed.length > 0 ? self.model.attributed : self
|
||||
.makeAttributed(from: self.model.text)
|
||||
let maxWidth = self.width - (self.padding * 2) - self.spacing - self.buttonWidth
|
||||
|
||||
let textInset = NSSize(width: 2, height: 6)
|
||||
let lineFragmentPadding: CGFloat = 0
|
||||
let containerWidth = max(1, maxWidth - (textInset.width * 2) - (lineFragmentPadding * 2))
|
||||
|
||||
let storage = NSTextStorage(attributedString: attributed)
|
||||
let container = NSTextContainer(containerSize: CGSize(width: containerWidth, height: .greatestFiniteMagnitude))
|
||||
container.lineFragmentPadding = lineFragmentPadding
|
||||
container.lineBreakMode = .byWordWrapping
|
||||
|
||||
let layout = NSLayoutManager()
|
||||
layout.addTextContainer(container)
|
||||
storage.addLayoutManager(layout)
|
||||
|
||||
_ = layout.glyphRange(for: container)
|
||||
let used = layout.usedRect(for: container)
|
||||
|
||||
let contentHeight = ceil(used.height + (textInset.height * 2))
|
||||
let total = contentHeight + self.verticalPadding * 2
|
||||
self.model.isOverflowing = total > self.maxHeight
|
||||
return max(self.minHeight, min(total, self.maxHeight))
|
||||
}
|
||||
|
||||
func dismissTargetFrame(for frame: NSRect, reason: DismissReason, outcome: SendOutcome) -> NSRect? {
|
||||
switch (reason, outcome) {
|
||||
case (.empty, _):
|
||||
let scale: CGFloat = 0.95
|
||||
let newSize = NSSize(width: frame.size.width * scale, height: frame.size.height * scale)
|
||||
let dx = (frame.size.width - newSize.width) / 2
|
||||
let dy = (frame.size.height - newSize.height) / 2
|
||||
return NSRect(x: frame.origin.x + dx, y: frame.origin.y + dy, width: newSize.width, height: newSize.height)
|
||||
case (.explicit, .sent):
|
||||
return frame.offsetBy(dx: 8, dy: 6)
|
||||
default:
|
||||
return frame
|
||||
}
|
||||
}
|
||||
}
|
||||
196
apps/macos/Sources/Clawdis/VoiceWakeOverlayTextViews.swift
Normal file
196
apps/macos/Sources/Clawdis/VoiceWakeOverlayTextViews.swift
Normal file
@@ -0,0 +1,196 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct TranscriptTextView: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
var attributed: NSAttributedString
|
||||
var isFinal: Bool
|
||||
var isOverflowing: Bool
|
||||
var onBeginEditing: () -> Void
|
||||
var onEscape: () -> Void
|
||||
var onEndEditing: () -> Void
|
||||
var onSend: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let textView = TranscriptNSTextView()
|
||||
textView.delegate = context.coordinator
|
||||
textView.drawsBackground = false
|
||||
textView.isRichText = true
|
||||
textView.isAutomaticQuoteSubstitutionEnabled = false
|
||||
textView.isAutomaticTextReplacementEnabled = false
|
||||
textView.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
textView.textContainer?.lineBreakMode = .byWordWrapping
|
||||
textView.textContainer?.lineFragmentPadding = 0
|
||||
textView.textContainerInset = NSSize(width: 2, height: 6)
|
||||
|
||||
textView.minSize = .zero
|
||||
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.isVerticallyResizable = true
|
||||
textView.autoresizingMask = [.width]
|
||||
|
||||
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
|
||||
textView.textStorage?.setAttributedString(self.attributed)
|
||||
textView.typingAttributes = [
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.font: NSFont.systemFont(ofSize: 13, weight: .regular),
|
||||
]
|
||||
textView.focusRingType = .none
|
||||
textView.onSend = { [weak textView] in
|
||||
textView?.window?.makeFirstResponder(nil)
|
||||
self.onSend()
|
||||
}
|
||||
textView.onBeginEditing = self.onBeginEditing
|
||||
textView.onEscape = self.onEscape
|
||||
textView.onEndEditing = self.onEndEditing
|
||||
|
||||
let scroll = NSScrollView()
|
||||
scroll.drawsBackground = false
|
||||
scroll.borderType = .noBorder
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.scrollerStyle = .overlay
|
||||
scroll.hasHorizontalScroller = false
|
||||
scroll.documentView = textView
|
||||
return scroll
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
guard let textView = scrollView.documentView as? TranscriptNSTextView else { return }
|
||||
let isEditing = scrollView.window?.firstResponder == textView
|
||||
if isEditing {
|
||||
return
|
||||
}
|
||||
|
||||
if !textView.attributedString().isEqual(to: self.attributed) {
|
||||
context.coordinator.isProgrammaticUpdate = true
|
||||
defer { context.coordinator.isProgrammaticUpdate = false }
|
||||
textView.textStorage?.setAttributedString(self.attributed)
|
||||
}
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||
var parent: TranscriptTextView
|
||||
var isProgrammaticUpdate = false
|
||||
|
||||
init(_ parent: TranscriptTextView) { self.parent = parent }
|
||||
|
||||
func textDidBeginEditing(_ notification: Notification) {
|
||||
self.parent.onBeginEditing()
|
||||
}
|
||||
|
||||
func textDidEndEditing(_ notification: Notification) {
|
||||
self.parent.onEndEditing()
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard !self.isProgrammaticUpdate else { return }
|
||||
guard let view = notification.object as? NSTextView else { return }
|
||||
guard view.window?.firstResponder === view else { return }
|
||||
self.parent.text = view.string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vibrant display label
|
||||
|
||||
struct VibrantLabelView: NSViewRepresentable {
|
||||
var attributed: NSAttributedString
|
||||
var onTap: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let label = NSTextField(labelWithAttributedString: self.attributed)
|
||||
label.isEditable = false
|
||||
label.isBordered = false
|
||||
label.drawsBackground = false
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.maximumNumberOfLines = 0
|
||||
label.usesSingleLineMode = false
|
||||
label.cell?.wraps = true
|
||||
label.cell?.isScrollable = false
|
||||
label.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
label.setContentHuggingPriority(.required, for: .vertical)
|
||||
label.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
label.textColor = .labelColor
|
||||
|
||||
let container = ClickCatcher(onTap: onTap)
|
||||
container.addSubview(label)
|
||||
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
label.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
label.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
label.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
])
|
||||
return container
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
guard let container = nsView as? ClickCatcher,
|
||||
let label = container.subviews.first as? NSTextField else { return }
|
||||
label.attributedStringValue = self.attributed.strippingForegroundColor()
|
||||
label.textColor = .labelColor
|
||||
}
|
||||
}
|
||||
|
||||
private final class ClickCatcher: NSView {
|
||||
let onTap: () -> Void
|
||||
init(onTap: @escaping () -> Void) {
|
||||
self.onTap = onTap
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
super.mouseDown(with: event)
|
||||
self.onTap()
|
||||
}
|
||||
}
|
||||
|
||||
private final class TranscriptNSTextView: NSTextView {
|
||||
var onSend: (() -> Void)?
|
||||
var onBeginEditing: (() -> Void)?
|
||||
var onEndEditing: (() -> Void)?
|
||||
var onEscape: (() -> Void)?
|
||||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
self.onBeginEditing?()
|
||||
return super.becomeFirstResponder()
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
let result = super.resignFirstResponder()
|
||||
self.onEndEditing?()
|
||||
return result
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
let isReturn = event.keyCode == 36
|
||||
let isEscape = event.keyCode == 53
|
||||
if isEscape {
|
||||
self.onEscape?()
|
||||
return
|
||||
}
|
||||
if isReturn, event.modifierFlags.contains(.command) {
|
||||
self.onSend?()
|
||||
return
|
||||
}
|
||||
if isReturn {
|
||||
if event.modifierFlags.contains(.shift) {
|
||||
super.insertNewline(nil)
|
||||
return
|
||||
}
|
||||
self.onSend?()
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
186
apps/macos/Sources/Clawdis/VoiceWakeOverlayView.swift
Normal file
186
apps/macos/Sources/Clawdis/VoiceWakeOverlayView.swift
Normal file
@@ -0,0 +1,186 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceWakeOverlayView: View {
|
||||
var controller: VoiceWakeOverlayController
|
||||
@FocusState private var textFocused: Bool
|
||||
@State private var isHovering: Bool = false
|
||||
@State private var closeHovering: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if self.controller.model.isEditing {
|
||||
TranscriptTextView(
|
||||
text: Binding(
|
||||
get: { self.controller.model.text },
|
||||
set: { self.controller.updateText($0) }),
|
||||
attributed: self.controller.model.attributed,
|
||||
isFinal: self.controller.model.isFinal,
|
||||
isOverflowing: self.controller.model.isOverflowing,
|
||||
onBeginEditing: {
|
||||
self.controller.userBeganEditing()
|
||||
},
|
||||
onEscape: {
|
||||
self.controller.cancelEditingAndDismiss()
|
||||
},
|
||||
onEndEditing: {
|
||||
self.controller.endEditing()
|
||||
},
|
||||
onSend: {
|
||||
self.controller.requestSend()
|
||||
})
|
||||
.focused(self.$textFocused)
|
||||
.frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading)
|
||||
.id("editing")
|
||||
} else {
|
||||
VibrantLabelView(
|
||||
attributed: self.controller.model.attributed,
|
||||
onTap: {
|
||||
self.controller.userBeganEditing()
|
||||
self.textFocused = true
|
||||
})
|
||||
.frame(maxWidth: .infinity, minHeight: 32, maxHeight: .infinity, alignment: .topLeading)
|
||||
.focusable(false)
|
||||
.id("display")
|
||||
}
|
||||
|
||||
Button {
|
||||
self.controller.requestSend()
|
||||
} label: {
|
||||
let sending = self.controller.model.isSending
|
||||
let level = self.controller.model.level
|
||||
ZStack {
|
||||
GeometryReader { geo in
|
||||
let width = geo.size.width
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.12))
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color.accentColor.opacity(0.25))
|
||||
.frame(width: width * max(0, min(1, level)), alignment: .leading)
|
||||
.animation(.easeOut(duration: 0.08), value: level)
|
||||
}
|
||||
.frame(height: 28)
|
||||
|
||||
ZStack {
|
||||
Image(systemName: "paperplane.fill")
|
||||
.opacity(sending ? 0 : 1)
|
||||
.scaleEffect(sending ? 0.5 : 1)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.opacity(sending ? 1 : 0)
|
||||
.scaleEffect(sending ? 1.05 : 0.8)
|
||||
}
|
||||
.imageScale(.small)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.frame(width: 32, height: 28)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.78), value: sending)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!self.controller.model.forwardEnabled || self.controller.model.isSending)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background {
|
||||
OverlayBackground()
|
||||
.equatable()
|
||||
}
|
||||
.shadow(color: Color.black.opacity(0.22), radius: 14, x: 0, y: -2)
|
||||
.onHover { self.isHovering = $0 }
|
||||
|
||||
// Close button rendered above and outside the clipped bubble
|
||||
CloseButtonOverlay(
|
||||
isVisible: self.controller.model.isEditing || self.isHovering || self.closeHovering,
|
||||
onHover: { self.closeHovering = $0 },
|
||||
onClose: { self.controller.cancelEditingAndDismiss() })
|
||||
}
|
||||
.padding(.top, self.controller.closeOverflow)
|
||||
.padding(.leading, self.controller.closeOverflow)
|
||||
.padding(.trailing, self.controller.closeOverflow)
|
||||
.padding(.bottom, self.controller.closeOverflow)
|
||||
.onAppear {
|
||||
self.updateFocusState(visible: self.controller.model.isVisible, editing: self.controller.model.isEditing)
|
||||
}
|
||||
.onChange(of: self.controller.model.isVisible) { _, visible in
|
||||
self.updateFocusState(visible: visible, editing: self.controller.model.isEditing)
|
||||
}
|
||||
.onChange(of: self.controller.model.isEditing) { _, editing in
|
||||
self.updateFocusState(visible: self.controller.model.isVisible, editing: editing)
|
||||
}
|
||||
.onChange(of: self.controller.model.attributed) { _, _ in
|
||||
self.controller.updateWindowFrame(animate: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFocusState(visible: Bool, editing: Bool) {
|
||||
let shouldFocus = visible && editing
|
||||
guard self.textFocused != shouldFocus else { return }
|
||||
self.textFocused = shouldFocus
|
||||
}
|
||||
}
|
||||
|
||||
private struct OverlayBackground: View {
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||
.clipShape(shape)
|
||||
.overlay(shape.strokeBorder(Color.white.opacity(0.16), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
extension OverlayBackground: @MainActor Equatable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool { true }
|
||||
}
|
||||
|
||||
struct CloseHoverButton: View {
|
||||
var onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(Color.white.opacity(0.85))
|
||||
.frame(width: 22, height: 22)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.black.opacity(0.35), radius: 6, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.contentShape(Circle())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
struct CloseButtonOverlay: View {
|
||||
var isVisible: Bool
|
||||
var onHover: (Bool) -> Void
|
||||
var onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if self.isVisible {
|
||||
Button(action: self.onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(Color.white.opacity(0.9))
|
||||
.frame(width: 22, height: 22)
|
||||
.background(Color.black.opacity(0.4))
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 10, x: 0, y: 3)
|
||||
.shadow(color: Color.black.opacity(0.2), radius: 2, x: 0, y: 0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
.contentShape(Circle())
|
||||
.padding(6)
|
||||
.onHover { self.onHover($0) }
|
||||
.offset(x: -9, y: -9)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(self.isVisible)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user