diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index f8e91056d..4fd54cedf 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -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? - private var autoSendTask: Task? - private var autoSendToken: UUID? - private var activeToken: UUID? - private var activeSource: Source? - private var lastLevelUpdate: TimeInterval = 0 + var window: NSPanel? + var hostingView: NSHostingView? + var autoSendTask: Task? + 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 { [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 diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlayController+Session.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlayController+Session.swift new file mode 100644 index 000000000..f021eac98 --- /dev/null +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlayController+Session.swift @@ -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 { [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), + ]) + } +} diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlayController+Testing.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlayController+Testing.swift new file mode 100644 index 000000000..af1111df9 --- /dev/null +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlayController+Testing.swift @@ -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 diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlayController+Window.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlayController+Window.swift new file mode 100644 index 000000000..bb7969754 --- /dev/null +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlayController+Window.swift @@ -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 + } + } +} diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlayTextViews.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlayTextViews.swift new file mode 100644 index 000000000..151db8c93 --- /dev/null +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlayTextViews.swift @@ -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) + } +} diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlayView.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlayView.swift new file mode 100644 index 000000000..48055c10a --- /dev/null +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlayView.swift @@ -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) + } +}