Files
clawdbot/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift
2025-12-12 22:09:14 +00:00

801 lines
31 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import OSLog
import QuartzCore
import SwiftUI
/// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar.
@MainActor
final class VoiceWakeOverlayController: ObservableObject {
static let shared = VoiceWakeOverlayController()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.overlay")
/// Keep the voice wake overlay above any other Clawdis windows, but below the systems pop-up menus.
/// (Menu bar menus typically live at `.popUpMenu`.)
private static let preferredWindowLevel = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4)
enum Source: String { case wakeWord, pushToTalk }
@Published private(set) var model = Model()
var isVisible: Bool { self.model.isVisible }
struct Model {
var text: String = ""
var isFinal: Bool = false
var isVisible: Bool = false
var forwardEnabled: Bool = false
var isSending: Bool = false
var attributed: NSAttributedString = .init(string: "")
var isOverflowing: Bool = false
var isEditing: Bool = false
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 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 closeOverflow: CGFloat = 10
@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.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
guard let window else { 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.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 }
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() {
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),
])
}
}
private struct VoiceWakeOverlayView: View {
@ObservedObject 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)
.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 {
let shape = RoundedRectangle(cornerRadius: 12, style: .continuous)
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
.clipShape(shape)
.overlay(shape.strokeBorder(Color.white.opacity(0.16), lineWidth: 1))
}
.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.textFocused = false }
.onChange(of: self.controller.model.text) { _, _ in
self.textFocused = self.controller.model.isEditing
}
.onChange(of: self.controller.model.isVisible) { _, visible in
if visible { self.textFocused = self.controller.model.isEditing }
}
.onChange(of: self.controller.model.isEditing) { _, editing in
self.textFocused = editing
}
.onChange(of: self.controller.model.attributed) { _, _ in
self.controller.updateWindowFrame(animate: true)
}
}
}
private 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
private 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 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)
}
}
private 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)
}
}