Coverage Report

Created: 2026-10-23 22:23

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: the running transcript of the current
4
//! recording plus the keyboard-driving update logic. The pure text algorithms
5
//! it builds on — normalization, the screen diff, window stitching — live in
6
//! [`crate::output::preview`].
7
8
use crate::output::keyboard::Simulator;
9
use crate::output::preview::{
10
    capitalize_first, find_common_prefix, merge_window_preview, normalize_text, preprocess_text,
11
    sanitize_for_typing,
12
};
13
use log::{debug, info, warn};
14
use super_stt_shared::models::protocol::PreviewSource;
15
16
/// The running transcript of the current recording.
17
#[derive(Default)]
18
pub struct State {
19
    /// The last normalized preview, so a repeat is not retyped.
20
    pub prev_text: String,
21
    /// Everything recognized so far this recording: a stream's own running
22
    /// transcript, or the daemon's sliding windows stitched together.
23
    pub full_session_text: String,
24
}
25
26
impl State {
27
    /// Fold one preview into the running transcript.
28
    ///
29
    /// A stream frame is the transcript so far and simply replaces it. A window
30
    /// frame is the last few seconds only, so it is stitched onto the
31
    /// transcript by the audio the two windows share.
32
    ///
33
    /// The old two-phase scheme — lock a "stabilized" prefix from consecutive
34
    /// previews, then graft each preview onto it by its last three characters —
35
    /// was built for previews that all start at the same audio. Sliding windows
36
    /// do not, so the prefix froze after the first seconds, and the three-char
37
    /// graft found the *rightmost* recurrence of the prefix's tail and dropped
38
    /// whatever lay between ("the cat sat on the mat and the dog" became
39
    /// "the cat sat on the dog").
40
25
    fn absorb(&mut self, preview: &str, source: PreviewSource) {
41
25
        self.full_session_text = match source {
42
4
            PreviewSource::Stream => preview.to_string(),
43
21
            PreviewSource::Window => merge_window_preview(&self.full_session_text, preview),
44
        };
45
25
    }
46
}
47
48
/// How long [`Typer::type_notice`] waits before typing, so the user's shortcut
49
/// keys can be fully released first.
50
///
51
/// A failure notice can be emitted within milliseconds of a keypress, at which
52
/// point the shortcut's modifier keys (Ctrl/Alt/Super/Shift) are usually still
53
/// physically held down. Simulated keystrokes sent during that window arrive
54
/// *modified*, so the focused application interprets the notice as shortcuts
55
/// rather than text.
56
///
57
/// Two distinct cases hit this window, which is why the wait applies to every
58
/// notice rather than just the first:
59
///
60
/// - The no-model preflight rejects the request before capture even starts, so
61
///   the notice follows the *start* keypress almost immediately.
62
/// - In manual stop mode the user presses the hotkey to end the recording, and
63
///   a failure that surfaces quickly after that — a model unloaded mid-cycle,
64
///   say — puts the notice right behind the *stop* keypress.
65
///
66
/// This does not apply to transcription output: that is typed after speech has
67
/// been captured and transcribed, long past any plausible key-release window.
68
const NOTICE_KEY_RELEASE_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
69
70
/// Types previews and the final transcript into the focused window.
71
pub struct Typer {
72
    // `None` for a recording that types nothing. Every keystroke path is gated
73
    // on write mode, so such a recording never reaches for the keyboard.
74
    keyboard_simulator: Option<Simulator>,
75
    state: State,
76
}
77
78
impl Typer {
79
    #[must_use]
80
33
    pub fn new(keyboard_simulator: Simulator) -> Self {
81
33
        Self {
82
33
            keyboard_simulator: Some(keyboard_simulator),
83
33
            state: State::default(),
84
33
        }
85
33
    }
86
87
    /// A typer for a recording that types nothing. Building a keyboard is not
88
    /// free: the portal backend asks the user for permission and waits for the
89
    /// answer.
90
    #[must_use]
91
5
    pub fn without_keyboard() -> Self {
92
5
        Self {
93
5
            keyboard_simulator: None,
94
5
            state: State::default(),
95
5
        }
96
5
    }
97
98
    #[must_use]
99
0
    pub fn write_method_name(&self) -> &'static str {
100
0
        self.keyboard_simulator
101
0
            .as_ref()
102
0
            .map_or("none", Simulator::name)
103
0
    }
104
105
    /// Extract the simulator so it can be cached for reuse.
106
    #[must_use]
107
5
    pub fn take_simulator(self) -> Option<Simulator> {
108
5
        self.keyboard_simulator
109
5
    }
110
111
34
    async fn type_text(&mut self, text: &str) -> anyhow::Result<()> {
112
34
        match self.keyboard_simulator.as_mut() {
113
34
            Some(keyboard) => keyboard.type_text(text).await,
114
0
            None => Err(anyhow::anyhow!("this recording has no keyboard")),
115
        }
116
34
    }
117
118
7
    async fn backspace_n(&mut self, n: usize) -> anyhow::Result<()> {
119
7
        match self.keyboard_simulator.as_mut() {
120
7
            Some(keyboard) => keyboard.backspace_n(n).await,
121
0
            None => Err(anyhow::anyhow!("this recording has no keyboard")),
122
        }
123
7
    }
124
125
    /// Drive the screen from `old_text` to `new_text`: backspace to the first
126
    /// differing character and type the rest.
127
    ///
128
    /// The one path every preview update takes. An empty screen (common prefix
129
    /// 0, nothing to delete) and a pure extension (the prefix is all of
130
    /// `old_text`, nothing to delete) are just what the diff computes, not
131
    /// separate branches that can disagree about what they typed.
132
25
    async fn retype_from_first_difference(&mut self, old_text: &str, new_text: &str) {
133
25
        if old_text == new_text {
134
0
            return;
135
25
        }
136
137
25
        let common_prefix = find_common_prefix(old_text, new_text);
138
25
        let chars_to_delete = old_text.chars().count() - common_prefix;
139
25
        let text_to_type: String = new_text.chars().skip(common_prefix).collect();
140
141
25
        debug!(
142
            "Retype: prefix={common_prefix}, delete={chars_to_delete}, type='{}'",
143
0
            text_to_type.chars().take(20).collect::<String>()
144
        );
145
146
        // A refused keystroke is the only sign that the screen and the mirror
147
        // may now disagree; it has to be visible in the log.
148
25
        if chars_to_delete > 0
149
4
            && let Err(
e0
) = self.backspace_n(chars_to_delete).await
150
        {
151
0
            warn!("Failed to backspace preview text: {e}");
152
25
        }
153
25
        if !text_to_type.is_empty()
154
25
            && let Err(
e0
) = self.type_text(&text_to_type).await
155
        {
156
0
            warn!("Failed to type preview text: {e}");
157
25
        }
158
25
    }
159
160
    /// Type one preview: fold it into the running transcript and put the
161
    /// result on screen. The screen shows the transcript so far, never the
162
    /// preview itself — a window is only a few seconds of it.
163
26
    pub async fn update_preview(
164
26
        &mut self,
165
26
        new_text: &str,
166
26
        source: PreviewSource,
167
26
        actually_typed: &mut String,
168
26
    ) {
169
26
        let normalized = normalize_text(new_text);
170
26
        debug!(
171
            "Preview update ({source}): new='{normalized}', prev='{}', typed='{actually_typed}'",
172
            self.state.prev_text
173
        );
174
175
26
        if normalized == self.state.prev_text {
176
1
            debug!("Preview unchanged; nothing to type");
177
1
            return;
178
25
        }
179
25
        if normalized.is_empty() {
180
0
            debug!("Preview is empty; nothing to type");
181
0
            return;
182
25
        }
183
184
25
        self.state.absorb(&normalized, source);
185
25
        let display_text = capitalize_first(&self.state.full_session_text);
186
        // The head of a running transcript stops changing after the first
187
        // seconds; its tail is where each window lands.
188
25
        info!("Transcript so far ends '{}'", 
tail_of0
(
&display_text0
, 40));
189
190
25
        self.apply_text_update(&display_text, actually_typed).await;
191
25
        self.state.prev_text = normalized;
192
26
    }
193
194
    /// Type the final transcript, then reset for the next recording.
195
5
    pub async fn process_final_text(&mut self, transcription_result: &str) {
196
        // No preview typing, type directly
197
5
        let processed_text = preprocess_text(transcription_result, false);
198
199
        // An empty transcript has nothing to type. Without this guard the
200
        // `format!("{processed_text} ")` below deposits a bare space into the
201
        // user's focused window every time a recording produces no text.
202
5
        if processed_text.trim().is_empty() {
203
3
            info!("Final transcription is empty; typing nothing");
204
3
            self.reset_after_recording();
205
3
            return;
206
2
        }
207
208
2
        let final_text = format!("{processed_text} ");
209
2
        if let Err(
e0
) = self.type_text(&final_text).await {
210
0
            warn!("Failed to type final transcription: {e}");
211
        } else {
212
2
            info!("Final transcript typed");
213
        }
214
215
2
        self.reset_after_recording();
216
5
    }
217
218
    /// Clear the per-recording transcript state so the next recording starts
219
    /// clean. Window stitching reads `full_session_text` and the repeat check
220
    /// reads `prev_text`, so anything left here would be extended.
221
    ///
222
    /// Split out of [`Self::process_final_text`] because the no-speech path
223
    /// finishes a recording without typing anything and still has to reset.
224
6
    pub fn reset_after_recording(&mut self) {
225
6
        self.state.prev_text.clear();
226
6
        self.state.full_session_text.clear();
227
6
    }
228
229
    /// Type a fixed daemon-authored notice into the focused window.
230
    ///
231
    /// Deliberately **not** `process_final_text`. That method mutates
232
    /// transcript state (`prev_text`, `full_session_text`), which the next
233
    /// recording's window stitching would extend, and applies transcript
234
    /// semantics — capitalization, a trailing period, a trailing space — that
235
    /// a fixed marker must not inherit. A notice is typed verbatim and leaves
236
    /// session state alone.
237
    ///
238
    /// Routed through the same [`sanitize_for_typing`] choke point as every
239
    /// other write path (audit 2 Tier 3 #8). The callers pass constants, so
240
    /// this is a no-op today; it is here so the property holds by
241
    /// construction.
242
    ///
243
    /// Waits [`NOTICE_KEY_RELEASE_DELAY`] before typing — see that constant for
244
    /// why.
245
7
    pub async fn type_notice(&mut self, notice: &str) {
246
        // Let the user's hotkey come back up first. The no-model preflight
247
        // rejects before capture starts, so this can run within milliseconds of
248
        // the press, while the shortcut's modifiers are still physically held.
249
        // Typing then delivers *modified* keystrokes to the focused window —
250
        // the notice would fire shortcuts in the user's application instead of
251
        // inserting text.
252
7
        tokio::time::sleep(NOTICE_KEY_RELEASE_DELAY).await;
253
254
7
        let sanitized = sanitize_for_typing(notice);
255
7
        if let Err(
e0
) = self.type_text(&sanitized).await {
256
0
            warn!("Failed to type notice: {e}");
257
        } else {
258
7
            info!("Typed failure notice: {sanitized}");
259
        }
260
7
    }
261
262
    /// Put `new_text` on screen in place of what `actually_typed` says is
263
    /// there, then record `new_text` as what is there now.
264
    ///
265
    /// The mirror has to be exactly the screen: `clear_preview` backspaces its
266
    /// length and the next update diffs against it. It used to drift by one
267
    /// space per update — the first text and every extension were typed with a
268
    /// trailing space the mirror never held — so extensions doubled spaces,
269
    /// replacements backspaced too few characters and left fragments of the
270
    /// old text behind, and the clear at the end of a recording left the first
271
    /// characters of the preview in front of the final transcript.
272
25
    async fn apply_text_update(&mut self, new_text: &str, actually_typed: &mut String) {
273
25
        self.retype_from_first_difference(actually_typed, new_text)
274
25
            .await;
275
276
25
        actually_typed.clear();
277
25
        actually_typed.push_str(new_text);
278
25
    }
279
280
    /// Erase the typed preview from the screen and reset the transcript state.
281
3
    pub async fn clear_preview(&mut self, actually_typed: &mut String) {
282
3
        let chars_to_delete = actually_typed.chars().count();
283
3
        if chars_to_delete == 0 {
284
0
            debug!("No preview on screen to clear");
285
        } else {
286
3
            info!("Clearing the {chars_to_delete}-character preview");
287
3
            if let Err(
e0
) = self.backspace_n(chars_to_delete).await {
288
0
                warn!("Failed to backspace preview text: {e}");
289
3
            }
290
        }
291
292
3
        actually_typed.clear();
293
3
        self.state.prev_text.clear();
294
3
        self.state.full_session_text.clear();
295
3
    }
296
}
297
298
/// The last `n` chars of `text`, marked when that is not all of it.
299
0
fn tail_of(text: &str, n: usize) -> String {
300
0
    let total = text.chars().count();
301
0
    if total <= n {
302
0
        return text.to_string();
303
0
    }
304
0
    format!("…{}", text.chars().skip(total - n).collect::<String>())
305
0
}
306
307
#[cfg(test)]
308
#[path = "typer_tests.rs"]
309
mod tests;