Skip to main content

murmur_core/context/
system_state.rs

1use std::collections::VecDeque;
2use std::sync::{Arc, Mutex};
3
4/// Maximum number of clipboard characters to capture as context.
5const MAX_CLIPBOARD_CHARS: usize = 500;
6
7/// Maximum number of characters to keep in the recent text buffer.
8const DEFAULT_MAX_RECENT_CHARS: usize = 1000;
9
10/// Maximum number of individual entries to keep.
11const MAX_ENTRIES: usize = 50;
12
13/// Provides the current clipboard text as context.
14///
15/// Reading the clipboard before dictation helps Whisper understand what the user
16/// might be referencing — e.g., if they just copied a function name, they're likely
17/// to say it during dictation.
18pub struct ClipboardWatcher;
19
20impl Default for ClipboardWatcher {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl ClipboardWatcher {
27    pub fn new() -> Self {
28        ClipboardWatcher
29    }
30
31    /// Read the current clipboard text, if any.
32    /// Returns None if clipboard is empty, contains non-text data, or access fails.
33    pub fn get_clipboard_text(&self) -> Option<String> {
34        match arboard::Clipboard::new() {
35            Ok(mut cb) => match cb.get_text() {
36                Ok(text) if !text.trim().is_empty() => {
37                    let trimmed = text.trim();
38                    if trimmed.chars().count() > MAX_CLIPBOARD_CHARS {
39                        Some(trimmed.chars().take(MAX_CLIPBOARD_CHARS).collect())
40                    } else {
41                        Some(trimmed.to_string())
42                    }
43                }
44                _ => None,
45            },
46            Err(e) => {
47                log::debug!("Failed to access clipboard: {e}");
48                None
49            }
50        }
51    }
52}
53
54/// Tracks recently dictated text to provide continuity context.
55///
56/// When the user dictates multiple utterances in sequence, the earlier ones
57/// provide valuable context for the later ones. This tracker maintains a
58/// rolling window of recent transcription output.
59pub struct RecentTextTracker {
60    buffer: Arc<Mutex<RecentTextBuffer>>,
61}
62
63struct RecentTextBuffer {
64    /// Recent text entries, newest last
65    entries: VecDeque<String>,
66    /// Maximum total characters to retain
67    max_chars: usize,
68}
69
70impl Default for RecentTextTracker {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl RecentTextTracker {
77    pub fn new() -> Self {
78        Self {
79            buffer: Arc::new(Mutex::new(RecentTextBuffer {
80                entries: VecDeque::new(),
81                max_chars: DEFAULT_MAX_RECENT_CHARS,
82            })),
83        }
84    }
85
86    /// Record a new transcription result.
87    pub fn push(&self, text: &str) {
88        let trimmed = text.trim();
89        if trimmed.is_empty() {
90            return;
91        }
92        if let Ok(mut buf) = self.buffer.lock() {
93            buf.entries.push_back(trimmed.to_string());
94            while buf.entries.len() > MAX_ENTRIES {
95                buf.entries.pop_front();
96            }
97            let mut total_chars: usize = buf.entries.iter().map(|e| e.len()).sum();
98            while total_chars > buf.max_chars && !buf.entries.is_empty() {
99                if let Some(front) = buf.entries.pop_front() {
100                    total_chars -= front.len();
101                }
102            }
103        }
104    }
105
106    /// Get the recent text as a single string (entries joined by spaces).
107    /// Returns None if no recent text exists.
108    pub fn get_recent_text(&self) -> Option<String> {
109        if let Ok(buf) = self.buffer.lock() {
110            if buf.entries.is_empty() {
111                return None;
112            }
113            Some(buf.entries.iter().cloned().collect::<Vec<_>>().join(" "))
114        } else {
115            None
116        }
117    }
118
119    /// Clear all tracked text.
120    pub fn clear(&self) {
121        if let Ok(mut buf) = self.buffer.lock() {
122            buf.entries.clear();
123        }
124    }
125
126    /// Get the number of tracked entries.
127    pub fn entry_count(&self) -> usize {
128        self.buffer.lock().map(|buf| buf.entries.len()).unwrap_or(0)
129    }
130
131    /// Get a clone of the tracker that shares the same buffer.
132    /// This is useful for sharing between the app loop and context providers.
133    pub fn shared(&self) -> Self {
134        Self {
135            buffer: Arc::clone(&self.buffer),
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    // -- ClipboardWatcher --
145
146    #[test]
147    fn test_clipboard_watcher_new() {
148        let watcher = ClipboardWatcher::new();
149        let _ = watcher;
150    }
151
152    #[test]
153    fn test_clipboard_watcher_returns_option() {
154        let watcher = ClipboardWatcher::new();
155        // In test environment, clipboard access may or may not work
156        let result = watcher.get_clipboard_text();
157        let _ = result; // Just verify it doesn't panic
158    }
159
160    #[test]
161    fn test_max_clipboard_chars_positive() {
162        const { assert!(MAX_CLIPBOARD_CHARS > 0) };
163    }
164
165    // -- RecentTextTracker --
166
167    #[test]
168    fn test_recent_text_tracker_new_empty() {
169        let tracker = RecentTextTracker::new();
170        assert!(tracker.get_recent_text().is_none());
171        assert_eq!(tracker.entry_count(), 0);
172    }
173
174    #[test]
175    fn test_recent_text_tracker_push_and_get() {
176        let tracker = RecentTextTracker::new();
177        tracker.push("hello world");
178        assert_eq!(tracker.entry_count(), 1);
179        let text = tracker.get_recent_text().unwrap();
180        assert_eq!(text, "hello world");
181    }
182
183    #[test]
184    fn test_recent_text_tracker_multiple_entries() {
185        let tracker = RecentTextTracker::new();
186        tracker.push("first");
187        tracker.push("second");
188        tracker.push("third");
189        assert_eq!(tracker.entry_count(), 3);
190        let text = tracker.get_recent_text().unwrap();
191        assert!(text.contains("first"));
192        assert!(text.contains("second"));
193        assert!(text.contains("third"));
194    }
195
196    #[test]
197    fn test_recent_text_tracker_ignores_empty() {
198        let tracker = RecentTextTracker::new();
199        tracker.push("");
200        tracker.push("   ");
201        tracker.push("\n\t");
202        assert_eq!(tracker.entry_count(), 0);
203        assert!(tracker.get_recent_text().is_none());
204    }
205
206    #[test]
207    fn test_recent_text_tracker_trims_whitespace() {
208        let tracker = RecentTextTracker::new();
209        tracker.push("  hello  ");
210        let text = tracker.get_recent_text().unwrap();
211        assert_eq!(text, "hello");
212    }
213
214    #[test]
215    fn test_recent_text_tracker_max_entries() {
216        let tracker = RecentTextTracker::new();
217        for i in 0..(MAX_ENTRIES + 10) {
218            tracker.push(&format!("entry {i}"));
219        }
220        assert!(tracker.entry_count() <= MAX_ENTRIES);
221    }
222
223    #[test]
224    fn test_recent_text_tracker_max_chars() {
225        let tracker = RecentTextTracker::new();
226        // Push a lot of text to exceed the character limit
227        for _ in 0..100 {
228            tracker.push(&"a".repeat(100));
229        }
230        let text = tracker.get_recent_text().unwrap();
231        assert!(text.len() <= DEFAULT_MAX_RECENT_CHARS + 200); // Allow some slack for joining
232    }
233
234    #[test]
235    fn test_recent_text_tracker_clear() {
236        let tracker = RecentTextTracker::new();
237        tracker.push("hello");
238        tracker.push("world");
239        assert_eq!(tracker.entry_count(), 2);
240        tracker.clear();
241        assert_eq!(tracker.entry_count(), 0);
242        assert!(tracker.get_recent_text().is_none());
243    }
244
245    #[test]
246    fn test_recent_text_tracker_shared() {
247        let tracker = RecentTextTracker::new();
248        let shared = tracker.shared();
249
250        tracker.push("from original");
251        assert_eq!(shared.entry_count(), 1);
252        assert_eq!(shared.get_recent_text().unwrap(), "from original");
253
254        shared.push("from shared");
255        assert_eq!(tracker.entry_count(), 2);
256    }
257
258    #[test]
259    fn test_recent_text_tracker_preserves_order() {
260        let tracker = RecentTextTracker::new();
261        tracker.push("alpha");
262        tracker.push("beta");
263        tracker.push("gamma");
264        let text = tracker.get_recent_text().unwrap();
265        let alpha_pos = text.find("alpha").unwrap();
266        let beta_pos = text.find("beta").unwrap();
267        let gamma_pos = text.find("gamma").unwrap();
268        assert!(alpha_pos < beta_pos);
269        assert!(beta_pos < gamma_pos);
270    }
271
272    #[test]
273    fn test_recent_text_tracker_thread_safe() {
274        let tracker = RecentTextTracker::new();
275        let shared = tracker.shared();
276
277        let handle = std::thread::spawn(move || {
278            for i in 0..10 {
279                shared.push(&format!("thread entry {i}"));
280            }
281        });
282
283        for i in 0..10 {
284            tracker.push(&format!("main entry {i}"));
285        }
286
287        handle.join().unwrap();
288        assert!(tracker.entry_count() > 0);
289        assert!(tracker.entry_count() <= 20);
290    }
291
292    // -- Constants --
293
294    #[test]
295    fn test_constants() {
296        const { assert!(DEFAULT_MAX_RECENT_CHARS > 0) };
297        const { assert!(MAX_ENTRIES > 0) };
298        const { assert!(MAX_CLIPBOARD_CHARS > 0) };
299    }
300}