Coverage Report

Created: 2026-07-21 00:46

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-daemon/src/output/typer.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
3
//! The preview typer state machine: session/stabilization state plus the
4
//! keyboard-driving update logic. The pure text-diff algorithms it builds on
5
//! live in [`crate::output::preview`].
6
7
use crate::output::keyboard::Simulator;
8
use crate::output::preview::{find_common_prefix, find_tail_match_in_text, preprocess_text};
9
use log::{debug, info, warn};
10
11
/// State for tracking preview updates
12
pub struct State {
13
    pub last_transcription: String,
14
    pub prev_text: String,
15
    /// Complete transcription built from all audio (for final output)
16
    pub full_session_text: String,
17
    /// When we last saw substantial text growth (to commit to full session)
18
    pub last_growth_time: std::time::Instant,
19
    /// History of transcriptions for stabilization
20
    pub text_storage: Vec<String>,
21
    /// Text confirmed by appearing in multiple transcriptions
22
    pub stabilized_text: String,
23
}
24
25
impl Default for State {
26
16
    fn default() -> Self {
27
16
        Self {
28
16
            last_transcription: String::new(),
29
16
            prev_text: String::new(),
30
16
            full_session_text: String::new(),
31
16
            last_growth_time: std::time::Instant::now(),
32
16
            text_storage: Vec::new(),
33
16
            stabilized_text: String::new(),
34
16
        }
35
16
    }
36
}
37
38
impl State {
39
    /// Stabilization and session text update (Phase 1).
40
    ///
41
    /// Keyboard-free: mutates only session/stabilization state, so it is
42
    /// independently testable.
43
4
    fn update_with_stabilization(&mut self, new_preview_text: &str) {
44
        // Add current text to storage
45
4
        self.text_storage.push(new_preview_text.to_string());
46
47
        // Keep only recent texts for stabilization (prevent unbounded growth)
48
4
        if self.text_storage.len() > 10 {
49
0
            self.text_storage.remove(0);
50
4
        }
51
52
        // Find common prefix between last two texts
53
4
        if self.text_storage.len() >= 2 {
54
2
            let last_two = &self.text_storage[self.text_storage.len() - 2..];
55
2
            let common_prefix = find_common_prefix(&last_two[0], &last_two[1]);
56
2
            let prefix_text = last_two[0].chars().take(common_prefix).collect::<String>();
57
58
            // Only update stabilized text if we found a longer stable prefix
59
2
            if prefix_text.len() > self.stabilized_text.len() {
60
2
                self.stabilized_text = prefix_text;
61
2
                debug!(
62
                    "Updated stabilized text: '{}'",
63
0
                    self.stabilized_text.chars().take(30).collect::<String>()
64
                );
65
0
            }
66
2
        }
67
68
        // Update full session text using stabilized text + tail matching
69
4
        self.update_full_session_text(new_preview_text);
70
4
    }
71
72
    /// Update the full session text using stabilized text as base.
73
10
    fn update_full_session_text(&mut self, new_preview_text: &str) {
74
        // If we have stabilized text, use it as our base
75
10
        if !self.stabilized_text.is_empty()
76
2
            && self.stabilized_text.len() > self.full_session_text.len()
77
        {
78
0
            self.full_session_text = self.stabilized_text.clone();
79
0
            self.last_growth_time = std::time::Instant::now();
80
0
            debug!(
81
                "Updated session from stabilized: '{}'",
82
0
                self.full_session_text.chars().take(30).collect::<String>()
83
            );
84
10
        }
85
86
        // Only grow the session text, never shrink it
87
10
        if self.full_session_text.is_empty() {
88
4
            self.full_session_text = new_preview_text.to_string();
89
4
            self.last_growth_time = std::time::Instant::now();
90
4
            debug!(
91
                "Started session text: '{}'",
92
0
                self.full_session_text.chars().take(30).collect::<String>()
93
            );
94
4
            return;
95
6
        }
96
97
        // Check if preview text extends our session text
98
6
        if new_preview_text.len() > self.full_session_text.len()
99
4
            && new_preview_text.starts_with(&self.full_session_text)
100
        {
101
            // Perfect extension - just grow
102
2
            self.full_session_text = new_preview_text.to_string();
103
2
            self.last_growth_time = std::time::Instant::now();
104
2
            debug!(
105
                "Extended session text to: '{}'",
106
0
                self.full_session_text.chars().take(40).collect::<String>()
107
            );
108
2
            return;
109
4
        }
110
111
        // Use tail matching to extend session with new content
112
4
        if let Some(
pos2
) = find_tail_match_in_text(&self.full_session_text, new_preview_text, 3) {
113
2
            let extended = format!("{}{}", self.full_session_text, &new_preview_text[pos..]);
114
2
            if extended.len() > self.full_session_text.len() {
115
2
                self.full_session_text = extended;
116
2
                self.last_growth_time = std::time::Instant::now();
117
2
                debug!(
118
                    "Extended session via tail match: '{}'",
119
0
                    self.full_session_text.chars().take(40).collect::<String>()
120
                );
121
0
            }
122
2
        }
123
10
    }
124
125
    /// Build the display text (Phase 2) - what actually shows on screen.
126
8
    fn build_display_text(&self, preview_text: &str) -> String {
127
        // Use stabilized text as base, but be smart about it
128
129
        // If no stabilized text yet, show the preview
130
8
        if self.stabilized_text.is_empty() {
131
2
            return preview_text.to_string();
132
6
        }
133
134
        // Try tail matching first
135
6
        if let Some(
pos2
) = find_tail_match_in_text(&self.stabilized_text, preview_text, 3) {
136
            // Found overlap - combine stabilized text with new part from preview
137
2
            return format!("{}{}", self.stabilized_text, &preview_text[pos..]);
138
4
        }
139
140
        // No tail match found - be conservative to avoid text loss
141
        // Prefer the longer text (session text or preview) to avoid disappearing words
142
4
        let best_text = if self.full_session_text.len() >= preview_text.len() {
143
2
            &self.full_session_text
144
        } else {
145
2
            preview_text
146
        };
147
148
4
        best_text.to_string()
149
8
    }
150
}
151
152
/// Unified, simplified preview typer that combines the best of both approaches
153
pub struct Typer {
154
    keyboard_simulator: Simulator,
155
    state: State,
156
}
157
158
impl Typer {
159
    #[must_use]
160
0
    pub fn new(keyboard_simulator: Simulator) -> Self {
161
0
        Self {
162
0
            keyboard_simulator,
163
0
            state: State::default(),
164
0
        }
165
0
    }
166
167
    #[must_use]
168
0
    pub fn write_method_name(&self) -> &'static str {
169
0
        self.keyboard_simulator.name()
170
0
    }
171
172
    /// Extract the simulator so it can be cached for reuse.
173
    #[must_use]
174
0
    pub fn take_simulator(self) -> Simulator {
175
0
        self.keyboard_simulator
176
0
    }
177
178
    /// Apply a simple differential update by backspacing to the first differing
179
    /// character and retyping the rest. Returns the **net change in screen
180
    /// characters** (chars typed minus chars deleted) so callers accounting in
181
    /// chars stay consistent — mixing this with a byte length would drift on any
182
    /// multibyte text.
183
0
    pub async fn apply_simple_diff(&mut self, old_text: &str, new_text: &str) -> isize {
184
        // Safety checks
185
0
        if old_text == new_text {
186
0
            return 0;
187
0
        }
188
189
0
        if old_text.is_empty() && !new_text.is_empty() {
190
0
            if let Err(e) = self.keyboard_simulator.type_text(new_text).await {
191
0
                debug!("Failed to type new text: {e}");
192
0
            }
193
0
            return isize::try_from(new_text.chars().count()).unwrap_or(isize::MAX);
194
0
        }
195
196
0
        if new_text.is_empty() {
197
            // Skip
198
0
            return 0;
199
0
        }
200
201
0
        let old_chars: Vec<char> = old_text.chars().collect();
202
0
        let new_chars: Vec<char> = new_text.chars().collect();
203
204
        // Find first different character position
205
0
        let common_prefix = find_common_prefix(old_text, new_text);
206
207
        // Calculate what to delete and what to type
208
0
        let chars_to_delete = old_chars.len() - common_prefix;
209
0
        let text_to_type: String = new_chars[common_prefix..].iter().collect();
210
0
        let chars_to_type = new_chars.len() - common_prefix;
211
212
0
        debug!(
213
            "Simple diff: prefix={}, delete={}, type='{}'",
214
            common_prefix,
215
            chars_to_delete,
216
0
            text_to_type.chars().take(20).collect::<String>()
217
        );
218
219
        // Backspace to the first different position
220
0
        let _ = self.keyboard_simulator.backspace_n(chars_to_delete).await;
221
222
        // Type the new part
223
0
        let _ = self.keyboard_simulator.type_text(&text_to_type).await;
224
225
        // Net screen delta in chars: what we added minus what we removed.
226
0
        isize::try_from(chars_to_type).unwrap_or(isize::MAX)
227
0
            - isize::try_from(chars_to_delete).unwrap_or(isize::MAX)
228
0
    }
229
230
    /// Update preview text using two-phase approach
231
0
    pub async fn update_preview(&mut self, new_text: &str, actually_typed: &mut String) {
232
0
        let processed_text = preprocess_text(new_text, true);
233
234
0
        info!(
235
            "Preview update: new='{}', prev='{}', typed='{}'",
236
0
            processed_text.chars().take(30).collect::<String>(),
237
0
            self.state.prev_text.chars().take(30).collect::<String>(),
238
0
            actually_typed.chars().take(30).collect::<String>()
239
        );
240
241
        // Skip if text hasn't changed
242
0
        if processed_text == self.state.prev_text {
243
0
            debug!("Text unchanged, skipping");
244
0
            return;
245
0
        }
246
247
        // Skip empty text
248
0
        if processed_text.is_empty() {
249
0
            debug!("Empty text, skipping");
250
0
            return;
251
0
        }
252
253
        // PHASE 1: Stabilization and session text update
254
0
        self.state.update_with_stabilization(&processed_text);
255
256
        // PHASE 2: Decide what to show on screen
257
0
        let display_text = self.state.build_display_text(&processed_text);
258
259
0
        info!(
260
            "Display logic: display='{}', session='{}', stabilized='{}'",
261
0
            display_text.chars().take(30).collect::<String>(),
262
0
            self.state
263
0
                .full_session_text
264
0
                .chars()
265
0
                .take(30)
266
0
                .collect::<String>(),
267
0
            self.state
268
0
                .stabilized_text
269
0
                .chars()
270
0
                .take(30)
271
0
                .collect::<String>()
272
        );
273
274
        // Apply the update to screen
275
0
        self.apply_text_update(&display_text, actually_typed).await;
276
0
        self.state.prev_text = processed_text;
277
0
    }
278
279
    /// Process final text (completed sentence) - Uses full session audio
280
0
    pub async fn process_final_text(&mut self, transcription_result: &str) {
281
        // No preview typing, type directly
282
0
        let processed_text = preprocess_text(transcription_result, false);
283
0
        let final_text = format!("{processed_text} ");
284
0
        if let Err(e) = self.keyboard_simulator.type_text(&final_text).await {
285
0
            warn!("Failed to type final transcription: {e}");
286
        } else {
287
0
            info!("Step 6 complete: Final transcription typed directly");
288
        }
289
290
        // Reset state for next sentence - but keep the full session text for user reference
291
0
        self.state.prev_text.clear();
292
0
        self.state.last_transcription = processed_text;
293
0
        self.state.last_growth_time = std::time::Instant::now();
294
295
0
        info!(
296
            "Completed sentence. Session text: '{}'",
297
0
            self.state
298
0
                .full_session_text
299
0
                .chars()
300
0
                .take(50)
301
0
                .collect::<String>()
302
        );
303
304
        // Clear session for next recording
305
0
        self.state.full_session_text.clear();
306
0
    }
307
308
    /// Apply text update to screen (common logic)
309
0
    async fn apply_text_update(&mut self, new_text: &str, actually_typed: &mut String) {
310
0
        info!(
311
            "Typing logic: old_typed='{}', new_display='{}'",
312
0
            actually_typed.chars().take(30).collect::<String>(),
313
0
            new_text.chars().take(30).collect::<String>(),
314
        );
315
316
0
        if actually_typed.is_empty() {
317
            // Screen is empty — type the whole thing.
318
0
            info!(
319
                "Screen empty, typing new text: '{}'",
320
0
                new_text.chars().take(30).collect::<String>()
321
            );
322
0
            let _ = self
323
0
                .keyboard_simulator
324
0
                .type_text(&format!("{new_text} "))
325
0
                .await;
326
0
        } else if new_text.starts_with(actually_typed.as_str())
327
0
            && new_text.len() > actually_typed.len()
328
        {
329
            // Perfect extension — append only the new suffix.
330
0
            let suffix = &new_text[actually_typed.len()..];
331
0
            info!("Perfect extension, adding suffix: '{suffix}'");
332
0
            let _ = self
333
0
                .keyboard_simulator
334
0
                .type_text(&format!("{suffix} "))
335
0
                .await;
336
        } else {
337
            // Replacement — backspace to the first difference and retype.
338
0
            let net_change = self.apply_simple_diff(actually_typed, new_text).await;
339
0
            info!("Diff replacement: net {net_change} char(s)");
340
        }
341
342
        // `actually_typed` mirrors what we drove onto the screen so
343
        // `clear_preview` backspaces the right count next time. Every branch
344
        // above leaves the screen showing `new_text`. The keyboard results are
345
        // best-effort and unchecked, so there is no measured count to reconcile
346
        // against — the old byte-vs-char reconciliation was both wrong (it added
347
        // `apply_simple_diff`'s byte length to a char count) and dead (both of
348
        // its branches did exactly this assignment).
349
0
        actually_typed.clear();
350
0
        actually_typed.push_str(new_text);
351
0
    }
352
353
    /// Clear all typed text and reset state
354
0
    pub async fn clear_preview(&mut self, actually_typed: &mut String) {
355
0
        info!("clear_preview called with actually_typed: '{actually_typed}'");
356
357
0
        if actually_typed.is_empty() {
358
0
            info!("actually_typed is empty, nothing to clear");
359
0
            return;
360
0
        }
361
362
0
        let chars_to_delete = actually_typed.chars().count();
363
0
        info!("Backspacing {chars_to_delete} characters");
364
365
0
        if let Err(e) = self.keyboard_simulator.backspace_n(chars_to_delete).await {
366
0
            warn!("Failed to backspace preview text: {e}");
367
        } else {
368
0
            info!("Successfully backspaced {chars_to_delete} characters");
369
        }
370
371
0
        actually_typed.clear();
372
373
        // Also clear state when explicitly clearing preview
374
0
        self.state.prev_text.clear();
375
0
        self.state.last_transcription.clear();
376
0
        self.state.full_session_text.clear();
377
0
        self.state.last_growth_time = std::time::Instant::now();
378
379
0
        info!("Cleared all {chars_to_delete} characters and reset state");
380
0
    }
381
}
382
383
#[cfg(test)]
384
#[path = "typer_tests.rs"]
385
mod tests;