typetui 0.2.1

A terminal-based typing test.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
use crate::content::{ContentProvider, Mode, SyntaxHighlighter};
use crate::stats::{Stats, StatsSnapshot};
use ratatui::style::Color;
use std::collections::HashSet;

#[derive(Debug, Clone)]
pub struct TypingTest {
    pub mode: Mode,
    pub language: String,
    pub target_text: String,
    pub user_input: String,
    pub cursor_position: usize,
    pub stats: Stats,
    pub duration_secs: u64,
    pub elapsed_secs: u64,
    pub is_active: bool,
    pub is_finished: bool,
    pub has_started: bool,
    /// Track shown code slices (`snippet_index`, `start_line`) to avoid repeats in code mode
    pub shown_code_slices: HashSet<(usize, usize)>,
    /// Per-character syntax highlight colors (only populated for code mode with supported languages)
    pub syntax_colors: Vec<Option<Color>>,
    /// Time-series data for charting WPM/accuracy over time
    pub time_series: Vec<StatsSnapshot>,
}

impl TypingTest {
    #[must_use]
    pub fn new(mode: Mode, language: String, duration_secs: u64) -> Self {
        let provider = ContentProvider::new(mode, &language);
        let shown_code_slices = HashSet::new();
        let target_text = if mode == Mode::Text {
            provider.generate_text(200)
        } else {
            // Generate a 15-line code snippet slice
            provider
                .generate_code_snippet_slice(&shown_code_slices)
                .map(|(_slice, id)| {
                    let mut set = shown_code_slices.clone();
                    set.insert(id);
                    set
                });
            // For code mode, we need to get the slice and update shown_code_slices
            match provider.generate_code_snippet_slice(&shown_code_slices) {
                Some((slice, id)) => {
                    let mut new_shown = shown_code_slices.clone();
                    new_shown.insert(id);
                    // We'll set shown_code_slices after Self is created
                    slice
                }
                None => "print('hello world')".to_string(),
            }
        };

        let shown_code_slices = if mode == Mode::Code {
            let provider = ContentProvider::new(mode, &language);
            match provider.generate_code_snippet_slice(&HashSet::new()) {
                Some((_, id)) => {
                    let mut set = HashSet::new();
                    set.insert(id);
                    set
                }
                None => HashSet::new(),
            }
        } else {
            HashSet::new()
        };

        // Compute syntax colors for code mode
        let syntax_colors = if mode == Mode::Code {
            let highlighter = SyntaxHighlighter::new();
            highlighter.highlight_code(&target_text, &language)
        } else {
            Vec::new()
        };

        Self {
            mode,
            language,
            target_text,
            user_input: String::new(),
            cursor_position: 0,
            stats: Stats::default(),
            duration_secs,
            elapsed_secs: 0,
            is_active: false,
            is_finished: false,
            has_started: false,
            shown_code_slices,
            syntax_colors,
            time_series: Vec::new(),
        }
    }

    pub fn start(&mut self) {
        self.is_active = true;
        self.has_started = true;
        // Record initial snapshot at time 0
        self.record_snapshot();
    }

    pub fn tick(&mut self) {
        if self.is_active && !self.is_finished {
            self.elapsed_secs += 1;
            self.record_snapshot();
            if self.elapsed_secs >= self.duration_secs {
                self.finish();
            }
        }
    }

    fn record_snapshot(&mut self) {
        self.time_series.push(StatsSnapshot {
            elapsed_secs: self.elapsed_secs,
            wpm: self.stats.wpm(self.elapsed_secs),
            raw_wpm: self.stats.raw_wpm(self.elapsed_secs),
            accuracy: self.stats.accuracy(),
        });
    }

    pub fn finish(&mut self) {
        self.is_active = false;
        self.is_finished = true;
    }

    pub fn handle_input(&mut self, c: char) {
        if self.is_finished {
            return;
        }

        if !self.has_started {
            self.start();
        }

        // In code mode, auto-skip leading indentation on first character input
        if self.mode == Mode::Code && self.cursor_position == 0 {
            self.skip_leading_indentation();
        }

        let expected = self.target_text.chars().nth(self.cursor_position);
        let correct = expected == Some(c);

        self.user_input.push(c);
        self.stats.record_char(correct);
        self.cursor_position += 1;

        if self.cursor_position >= self.target_text.len() {
            if self.mode == Mode::Code {
                self.load_next_code_snippet();
            } else {
                self.finish();
            }
        }
    }

    /// Skip leading whitespace/indentation at the start of the target text.
    /// Used in code mode to auto-advance to the first non-whitespace character.
    fn skip_leading_indentation(&mut self) {
        let leading_whitespace: String = self
            .target_text
            .chars()
            .take_while(|c| c.is_whitespace() && *c != '\n')
            .collect();

        for ws_char in leading_whitespace.chars() {
            self.user_input.push(ws_char);
            self.stats.record_char(true); // Mark as correct
            self.cursor_position += 1;
        }
    }

    /// Load the next 15-line code snippet slice for code mode.
    /// Resets user input while keeping the timer running.
    fn load_next_code_snippet(&mut self) {
        let provider = ContentProvider::new(self.mode, &self.language);
        match provider.generate_code_snippet_slice(&self.shown_code_slices) {
            Some((slice, id)) => {
                self.target_text = slice;
                self.user_input.clear();
                self.cursor_position = 0;
                self.shown_code_slices.insert(id);
                // Recompute syntax colors for the new snippet
                let highlighter = SyntaxHighlighter::new();
                self.syntax_colors = highlighter.highlight_code(&self.target_text, &self.language);
            }
            None => {
                // No more snippets available, finish the test
                self.finish();
            }
        }
    }

    /// Skip to the next newline by filling remaining characters on current line
    /// with incorrect placeholders. Returns true if a newline was found and skipped to.
    /// This is used when user presses Enter mid-line.
    pub fn skip_to_end_of_line(&mut self) -> bool {
        if self.is_finished {
            return false;
        }

        if !self.has_started {
            self.start();
        }

        let target_chars: Vec<char> = self.target_text.chars().collect();

        // Find the next newline from current cursor position
        #[allow(clippy::needless_range_loop)]
        for i in self.cursor_position..target_chars.len() {
            if target_chars[i] == '\n' {
                // Fill all characters from current position to (but not including) the newline
                // with a placeholder that will be marked incorrect
                for _ in self.cursor_position..i {
                    // Use a character that's unlikely to match anything in target
                    self.user_input.push('\0');
                    self.stats.record_char(false); // Mark as incorrect
                }
                self.cursor_position = i;
                return true;
            }
        }

        false
    }

    pub fn handle_backspace(&mut self) {
        if !self.is_active || self.is_finished || self.cursor_position == 0 {
            return;
        }

        self.stats.record_backspace();

        // In code mode, if current line only has leading whitespace (indentation),
        // remove the entire line (newline + all indent) to go to previous line's end
        if self.mode == Mode::Code && self.is_at_line_start() {
            // Remove all characters back to and including the previous newline
            let chars_to_remove = self
                .user_input
                .chars()
                .rev()
                .take_while(|c| *c != '\n')
                .count()
                + 1; // +1 for the newline itself

            let new_len = self.user_input.len().saturating_sub(chars_to_remove);
            self.user_input.truncate(new_len);
            self.cursor_position = self.user_input.len();
        } else {
            self.user_input.pop();
            self.cursor_position -= 1;
        }
    }

    /// Check if cursor is at the start of a line (only whitespace typed since last newline).
    /// Returns true if current line consists only of leading whitespace/indentation.
    fn is_at_line_start(&self) -> bool {
        // Find the position after the last newline in user_input
        let last_newline_pos = self.user_input.rfind('\n');
        let current_line_start = match last_newline_pos {
            Some(pos) => pos + 1,
            None => 0,
        };

        // Check if everything from current_line_start to end is whitespace
        self.user_input
            .chars()
            .skip(current_line_start)
            .all(char::is_whitespace)
    }

    #[must_use]
    pub fn get_display_text(&self, max_chars: usize) -> (String, String, String) {
        let typed = self.user_input.chars().take(max_chars).collect::<String>();
        let remaining = self
            .target_text
            .chars()
            .skip(self.cursor_position)
            .take(max_chars.saturating_sub(typed.chars().count()))
            .collect::<String>();
        let overflow = if self.cursor_position + remaining.chars().count() < self.target_text.len()
        {
            "...".to_string()
        } else {
            String::new()
        };

        (typed, remaining, overflow)
    }

    #[must_use]
    pub fn current_char_status(&self) -> Vec<(char, CharStatus)> {
        let mut result = Vec::new();

        for (i, target_char) in self.target_text.chars().enumerate() {
            let status = if i >= self.user_input.len() {
                CharStatus::Untyped
            } else {
                let typed_char = self.user_input.chars().nth(i).unwrap();
                if typed_char == target_char {
                    CharStatus::Correct
                } else {
                    CharStatus::Incorrect
                }
            };
            result.push((target_char, status));
        }

        result
    }

    #[must_use]
    pub fn wpm(&self) -> f64 {
        self.stats.wpm(self.elapsed_secs.max(1))
    }

    #[must_use]
    pub fn raw_wpm(&self) -> f64 {
        self.stats.raw_wpm(self.elapsed_secs.max(1))
    }

    #[must_use]
    pub fn accuracy(&self) -> f64 {
        self.stats.accuracy()
    }

    #[must_use]
    pub fn progress_pct(&self) -> f64 {
        if self.target_text.is_empty() {
            return 0.0;
        }
        (self.cursor_position as f64 / self.target_text.len() as f64) * 100.0
    }

    #[must_use]
    pub fn remaining_secs(&self) -> u64 {
        self.duration_secs.saturating_sub(self.elapsed_secs)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CharStatus {
    Untyped,
    Correct,
    Incorrect,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_typing_flow() {
        let mut test = TypingTest::new(Mode::Text, "english".to_string(), 60);
        test.start();

        assert!(test.is_active);
        assert!(test.has_started);
    }

    #[test]
    fn test_handle_input() {
        let mut test = TypingTest::new(Mode::Text, "english".to_string(), 60);
        test.target_text = "hello world".to_string();
        test.start();

        for c in "hello".chars() {
            test.handle_input(c);
        }

        assert_eq!(test.cursor_position, 5);
        assert_eq!(test.user_input, "hello");
    }

    #[test]
    fn test_backspace() {
        let mut test = TypingTest::new(Mode::Text, "english".to_string(), 60);
        test.target_text = "hello".to_string();
        test.start();

        test.handle_input('h');
        test.handle_input('e');
        assert_eq!(test.cursor_position, 2);

        test.handle_backspace();
        assert_eq!(test.cursor_position, 1);
        assert_eq!(test.user_input, "h");
    }

    #[test]
    fn test_char_status() {
        let mut test = TypingTest::new(Mode::Text, "english".to_string(), 60);
        test.target_text = "abc".to_string();
        test.start();

        test.handle_input('a');
        test.handle_input('x'); // incorrect
        test.handle_input('c');

        let status = test.current_char_status();
        assert_eq!(status[0].1, CharStatus::Correct);
        assert_eq!(status[1].1, CharStatus::Incorrect);
        assert_eq!(status[2].1, CharStatus::Correct);
    }
}