From 7a0830de1554780e71a2066470c557e2f4e1c326 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 8 Dec 2025 16:41:33 +0100 Subject: [PATCH] feat: tint partial transcripts and stabilize delays --- .../Sources/Clawdis/VoiceWakeOverlay.swift | 20 +++++++++---- .../Sources/Clawdis/VoiceWakeRuntime.swift | 30 ++++++++++++++----- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift index cbe9629da..5fd0dd00b 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeOverlay.swift @@ -15,6 +15,7 @@ final class VoiceWakeOverlayController: ObservableObject { var isVisible: Bool = false var forwardEnabled: Bool = false var isSending: Bool = false + var attributed: NSAttributedString = NSAttributedString(string: "") } private var window: NSPanel? @@ -25,23 +26,25 @@ final class VoiceWakeOverlayController: ObservableObject { private let width: CGFloat = 360 private let padding: CGFloat = 10 - func showPartial(transcript: String) { + func showPartial(transcript: String, attributed: NSAttributedString? = nil) { self.autoSendTask?.cancel() self.forwardConfig = nil self.model.text = transcript self.model.isFinal = false self.model.forwardEnabled = false self.model.isSending = false + self.model.attributed = attributed ?? NSAttributedString(string: transcript) self.present() } - func presentFinal(transcript: String, forwardConfig: VoiceWakeForwardConfig, delay: TimeInterval) { + func presentFinal(transcript: String, forwardConfig: VoiceWakeForwardConfig, delay: TimeInterval, attributed: NSAttributedString? = nil) { self.autoSendTask?.cancel() self.forwardConfig = forwardConfig self.model.text = transcript self.model.isFinal = true self.model.forwardEnabled = forwardConfig.enabled self.model.isSending = false + self.model.attributed = attributed ?? NSAttributedString(string: transcript) self.present() self.scheduleAutoSend(after: delay) } @@ -54,6 +57,7 @@ final class VoiceWakeOverlayController: ObservableObject { func updateText(_ text: String) { self.model.text = text self.model.isSending = false + self.model.attributed = NSAttributedString(string: text) self.updateWindowFrame(animate: true) } @@ -198,6 +202,7 @@ private struct VoiceWakeOverlayView: View { text: Binding( get: { self.controller.model.text }, set: { self.controller.updateText($0) }), + attributed: self.controller.model.attributed, isFinal: self.controller.model.isFinal, onBeginEditing: { self.controller.userBeganEditing() @@ -249,6 +254,7 @@ private struct VoiceWakeOverlayView: View { private struct TranscriptTextView: NSViewRepresentable { @Binding var text: String + var attributed: NSAttributedString var isFinal: Bool var onBeginEditing: () -> Void var onSend: () -> Void @@ -286,10 +292,14 @@ private struct TranscriptTextView: NSViewRepresentable { func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? TranscriptNSTextView else { return } - if textView.string != self.text { - textView.string = self.text + let isEditing = scrollView.window?.firstResponder == textView + if isEditing { + if textView.string != self.text { + textView.string = self.text + } + } else { + textView.textStorage?.setAttributedString(self.attributed) } - textView.textColor = self.isFinal ? .labelColor : .secondaryLabelColor } final class Coordinator: NSObject, NSTextViewDelegate { diff --git a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift index 57f3fd1d4..2ed20608e 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift @@ -2,6 +2,9 @@ import AVFoundation import Foundation import OSLog import Speech +#if canImport(AppKit) +import AppKit +#endif /// Background listener that keeps the voice-wake pipeline alive outside the settings test view. actor VoiceWakeRuntime { @@ -148,8 +151,9 @@ actor VoiceWakeRuntime { self.capturedTranscript = trimmed self.updateHeardBeyondTrigger(withTrimmed: trimmed) let snapshot = self.capturedTranscript + let attributed = Self.makeAttributed(transcript: snapshot, isFinal: false) await MainActor.run { - VoiceWakeOverlayController.shared.showPartial(transcript: snapshot) + VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed) } } } @@ -184,8 +188,9 @@ actor VoiceWakeRuntime { self.heardBeyondTrigger = !trimmed.isEmpty let snapshot = self.capturedTranscript + let attributed = Self.makeAttributed(transcript: snapshot, isFinal: false) await MainActor.run { - VoiceWakeOverlayController.shared.showPartial(transcript: snapshot) + VoiceWakeOverlayController.shared.showPartial(transcript: snapshot, attributed: attributed) } await MainActor.run { AppStateStore.shared.triggerVoiceEars(ttl: nil) } @@ -240,25 +245,27 @@ actor VoiceWakeRuntime { let forwardConfig = await MainActor.run { AppStateStore.shared.voiceWakeForwardConfig } let delay: TimeInterval = (heardBeyondTrigger && !finalTranscript.isEmpty) ? 1.0 : 3.0 + let finalAttributed = Self.makeAttributed(transcript: finalTranscript, isFinal: true) await MainActor.run { VoiceWakeOverlayController.shared.presentFinal( transcript: finalTranscript, forwardConfig: forwardConfig, - delay: delay) + delay: delay, + attributed: finalAttributed) } self.cooldownUntil = Date().addingTimeInterval(self.debounceAfterSend) self.restartRecognizer() } - private func restartRecognizer() { +private func restartRecognizer() { // Restart the recognizer so we listen for the next trigger with a clean buffer. let current = self.currentConfig self.stop() if let current { Task { await self.start(with: current) } } - } +} private func updateHeardBeyondTrigger(withTrimmed trimmed: String) { if !self.heardBeyondTrigger, !trimmed.isEmpty { @@ -286,11 +293,20 @@ actor VoiceWakeRuntime { static func _testHasContentAfterTrigger(_ text: String, triggers: [String]) -> Bool { !self.trimmedAfterTrigger(text, triggers: triggers).isEmpty } - #endif - #if DEBUG + static func _testAttributedColor(isFinal: Bool) -> NSColor { + self.makeAttributed(transcript: "sample", isFinal: isFinal) + .attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear + } + static func _testMatches(text: String, triggers: [String]) -> Bool { self.matches(text: text, triggers: triggers) } #endif + + private static func makeAttributed(transcript: String, isFinal: Bool) -> NSAttributedString { + let color: NSColor = isFinal ? .labelColor : .secondaryLabelColor + let attrs: [NSAttributedString.Key: Any] = [.foregroundColor: color] + return NSAttributedString(string: transcript, attributes: attrs) + } }