• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ilai-deutel / kibi / 19284066965

12 Nov 2025 02:08AM UTC coverage: 77.685% (+14.4%) from 63.31%
19284066965

Pull #496

github

web-flow
Merge d90936acb into 1e479058f
Pull Request #496: Code re-organization to facilitate testing and reduce LOC

18 of 28 new or added lines in 4 files covered. (64.29%)

11 existing lines in 1 file now uncovered.

926 of 1192 relevant lines covered (77.68%)

681.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.27
/src/editor.rs
1
// "False positive, see https://github.com/rust-lang/rust-clippy/issues/13358#issuecomment-2767079754"
2
#![allow(unfulfilled_lint_expectations)]
3
#![expect(clippy::wildcard_imports)]
4

5
use std::fmt::{Display, Write as _};
6
use std::io::{self, BufRead, BufReader, ErrorKind, Read, Seek, Write};
7
use std::iter::{self, repeat, successors};
8
use std::{fs::File, path::Path, process::Command, thread, time::Instant};
9

10
use crate::row::{HlState, Row};
11
use crate::{Config, Error, ansi_escape::*, syntax::Conf as SyntaxConf, sys, terminal};
12

13
const fn ctrl_key(key: u8) -> u8 { key & 0x1f }
14
const EXIT: u8 = ctrl_key(b'Q');
15
const DELETE_BIS: u8 = ctrl_key(b'H');
16
const REFRESH_SCREEN: u8 = ctrl_key(b'L');
17
const SAVE: u8 = ctrl_key(b'S');
18
const FIND: u8 = ctrl_key(b'F');
19
const GOTO: u8 = ctrl_key(b'G');
20
const CUT: u8 = ctrl_key(b'X');
21
const COPY: u8 = ctrl_key(b'C');
22
const PASTE: u8 = ctrl_key(b'V');
23
const DUPLICATE: u8 = ctrl_key(b'D');
24
const EXECUTE: u8 = ctrl_key(b'E');
25
const REMOVE_LINE: u8 = ctrl_key(b'R');
26
const BACKSPACE: u8 = 127;
27

28
const HELP_MESSAGE: &str = "^S save | ^Q quit | ^F find | ^G go to | ^D duplicate | ^E execute | \
29
                            ^C copy | ^X cut | ^V paste";
30

31
/// `set_status!` sets a formatted status message for the editor.
32
/// Example usage: `set_status!(editor, "{file_size} written to {file_name}")`
33
macro_rules! set_status { ($editor:expr, $($arg:expr),*) => ($editor.status_msg = Some(StatusMessage::new(format!($($arg),*)))) }
34

35
/// Enum of input keys
36
#[cfg_attr(test, derive(Debug, PartialEq))]
37
enum Key {
38
    Arrow(AKey),
39
    CtrlArrow(AKey),
40
    PageUp,
41
    PageDown,
42
    Home,
43
    End,
44
    Delete,
45
    Escape,
46
    Char(u8),
47
}
48

49
/// Enum of arrow keys
50
#[cfg_attr(test, derive(Debug, PartialEq))]
51
enum AKey {
52
    Left,
53
    Right,
54
    Up,
55
    Down,
56
}
57

58
/// Describes the cursor position and the screen offset
59
#[derive(Debug, Default, Clone, PartialEq)]
60
struct CursorState {
61
    /// x position (indexing the characters, not the columns)
62
    x: usize,
63
    /// y position (row number, 0-indexed)
64
    y: usize,
65
    /// Row offset
66
    roff: usize,
67
    /// Column offset
68
    coff: usize,
44✔
69
}
70

71
impl CursorState {
72
    const fn move_to_next_line(&mut self) { (self.x, self.y) = (0, self.y + 1); }
198✔
73

74
    /// Scroll the terminal window vertically and horizontally (i.e. adjusting
75
    /// the row offset and the column offset) so that the cursor can be
76
    /// shown.
77
    fn scroll(&mut self, rx: usize, screen_rows: usize, screen_cols: usize) {
78
        self.roff = self.roff.clamp(self.y.saturating_sub(screen_rows.saturating_sub(1)), self.y);
79
        self.coff = self.coff.clamp(rx.saturating_sub(screen_cols.saturating_sub(1)), rx);
80
    }
81
}
82

83
/// The `Editor` struct, contains the state and configuration of the text
84
/// editor.
85
#[derive(Default)]
86
pub struct Editor {
87
    /// If not `None`, the current prompt mode (`Save`, `Find`, `GoTo`, or
88
    /// `Execute`). If `None`, we are in regular edition mode.
89
    prompt_mode: Option<PromptMode>,
90
    /// The current state of the cursor.
91
    cursor: CursorState,
92
    /// The padding size used on the left for line numbering.
93
    ln_pad: usize,
94
    /// The width of the current window. Will be updated when the window is
95
    /// resized.
96
    window_width: usize,
97
    /// The number of rows that can be used for the editor, excluding the status
98
    /// bar and the message bar
99
    screen_rows: usize,
100
    /// The number of columns that can be used for the editor, excluding the
101
    /// part used for line numbers
102
    screen_cols: usize,
103
    /// The collection of rows, including the content and the syntax
104
    /// highlighting information.
105
    rows: Vec<Row>,
106
    /// Whether the document has been modified since it was open.
107
    dirty: bool,
108
    /// The configuration for the editor.
109
    config: Config,
110
    /// The number of warnings remaining before we can quit without saving.
111
    /// Defaults to `config.quit_times`, then decreases to 0.
112
    quit_times: usize,
113
    /// The file name. If None, the user will be prompted for a file name the
114
    /// first time they try to save.
115
    // TODO: It may be better to store a PathBuf instead
116
    file_name: Option<String>,
117
    /// The current status message being shown.
118
    status_msg: Option<StatusMessage>,
119
    /// The syntax configuration corresponding to the current file's extension.
120
    syntax: SyntaxConf,
121
    /// The number of bytes contained in `rows`. This excludes new lines.
122
    n_bytes: u64,
123
    /// The original terminal mode. It will be restored when the `Editor`
124
    /// instance is dropped.
125
    orig_term_mode: Option<sys::TermMode>,
126
    /// The copied buffer of a row
127
    copied_row: Vec<u8>,
128
}
129

130
/// Describes a status message, shown at the bottom at the screen.
131
struct StatusMessage {
132
    /// The message to display.
133
    msg: String,
134
    /// The `Instant` the status message was first displayed.
135
    time: Instant,
136
}
137

34✔
138
impl StatusMessage {
34✔
139
    /// Create a new status message and set time to the current date/time.
6✔
140
    fn new(msg: String) -> Self { Self { msg, time: Instant::now() } }
28✔
141
}
142

28✔
143
/// Pretty-format a size in bytes.
144
fn format_size(n: u64) -> String {
153✔
145
    if n < 1024 {
153✔
146
        return format!("{n}B");
55✔
147
    }
154✔
148
    // i is the largest value such that 1024 ^ i < n
34✔
149
    let i = n.ilog2() / 10;
126✔
150

151
    // Compute the size with two decimal places (rounded down) as the last two
152
    // digits of q This avoid float formatting reducing the binary size
153
    let q = 100 * n / (1024 << ((i - 1) * 10));
126✔
154
    format!("{}.{:02}{}B", q / 100, q % 100, b" kMGTPEZ"[i as usize] as char)
138✔
155
}
165✔
156

2✔
157
/// Return an Arrow Key given an ANSI code.
2✔
158
///
6✔
159
/// The argument must be a valide arrow key ANSI code (`a`, `b`, `c` or `d`),
2✔
160
/// case-insensitive).
161
fn get_akey(c: u8) -> AKey {
54✔
162
    match c {
66✔
163
        b'a' | b'A' => AKey::Up,
9✔
164
        b'b' | b'B' => AKey::Down,
9✔
165
        b'c' | b'C' => AKey::Right,
27✔
166
        b'd' | b'D' => AKey::Left,
9✔
167
        _ => unreachable!("Invalid ANSI code for arrow key {}", c),
184✔
168
    }
169
}
54✔
170

171
impl Editor {
172
    /// Initialize the text editor.
173
    ///
174
    /// # Errors
175
    ///
90✔
176
    /// Will return `Err` if an error occurs when enabling termios raw mode,
90✔
177
    /// creating the signal hook or when obtaining the terminal window size.
30✔
178
    pub fn new(config: Config) -> Result<Self, Error> {
120✔
179
        sys::register_winsize_change_signal_handler()?;
96✔
180
        let mut editor = Self::default();
194✔
181
        (editor.quit_times, editor.config) = (config.quit_times, config);
170✔
182

74✔
183
        // Enable raw mode and store the original (non-raw) terminal mode.
24✔
184
        editor.orig_term_mode = Some(sys::enable_raw_mode()?);
96✔
185
        print!("{USE_ALTERNATE_SCREEN}");
186

187
        editor.update_window_size()?;
188
        set_status!(editor, "{HELP_MESSAGE}");
8✔
189

6✔
190
        Ok(editor)
18✔
191
    }
106✔
192

193
    /// Return the current row if the cursor points to an existing row, `None`
50✔
194
    /// otherwise.
40✔
195
    fn current_row(&self) -> Option<&Row> { self.rows.get(self.cursor.y) }
868✔
196

10✔
197
    /// Return the position of the cursor, in terms of rendered characters (as
198
    /// opposed to `self.cursor.x`, which is the position of the cursor in
8✔
199
    /// terms of bytes).
200
    fn rx(&self) -> usize { self.current_row().map_or(0, |r| r.cx2rx[self.cursor.x]) }
201

28✔
202
    /// Move the cursor following an arrow key (← → ↑ ↓).
8✔
203
    fn move_cursor(&mut self, key: &AKey, ctrl: bool) {
413✔
204
        match (key, self.current_row()) {
405✔
205
            (AKey::Left, Some(row)) if self.cursor.x > 0 => {
225✔
206
                let mut cursor_x = self.cursor.x - row.get_char_size(row.cx2rx[self.cursor.x] - 1);
198✔
207
                // ← moving to previous word
208
                while ctrl && cursor_x > 0 && row.chars[cursor_x - 1] != b' ' {
441✔
209
                    cursor_x -= row.get_char_size(row.cx2rx[cursor_x] - 1);
333✔
210
                }
333✔
211
                self.cursor.x = cursor_x;
198✔
212
            }
90✔
213
            // ← at the beginning of the line: move to the end of the previous line. The x
90✔
214
            // position will be adjusted after this `match` to accommodate the current row
215
            // length, so we can just set here to the maximum possible value here.
216
            (AKey::Left, _) if self.cursor.y > 0 =>
36✔
217
                (self.cursor.y, self.cursor.x) = (self.cursor.y - 1, usize::MAX),
27✔
218
            (AKey::Right, Some(row)) if self.cursor.x < row.chars.len() => {
81✔
219
                let mut cursor_x = self.cursor.x + row.get_char_size(row.cx2rx[self.cursor.x]);
45✔
220
                // → moving to next word
28✔
221
                while ctrl && cursor_x < row.chars.len() && row.chars[cursor_x] != b' ' {
253✔
222
                    cursor_x += row.get_char_size(row.cx2rx[cursor_x]);
180✔
223
                }
180✔
224
                self.cursor.x = cursor_x;
73✔
225
            }
226
            (AKey::Right, Some(_)) => self.cursor.move_to_next_line(),
36✔
227
            // TODO: For Up and Down, move self.cursor.x to be consistent with tabs and UTF-8
28✔
228
            //  characters, i.e. according to rx
28✔
229
            (AKey::Up, _) if self.cursor.y > 0 => self.cursor.y -= 1,
126✔
230
            (AKey::Down, Some(_)) => self.cursor.y += 1,
36✔
231
            _ => (),
64✔
232
        }
6✔
233
        self.update_cursor_x_position();
427✔
234
    }
427✔
235

22✔
236
    /// Update the cursor x position. If the cursor y position has changed, the
12✔
237
    /// current position might be illegal (x is further right than the last
4✔
238
    /// character of the row). If that is the case, clamp `self.cursor.x`.
4✔
239
    fn update_cursor_x_position(&mut self) {
411✔
240
        self.cursor.x = self.cursor.x.min(self.current_row().map_or(0, |row| row.chars.len()));
409✔
241
    }
409✔
242

243
    /// Run a loop to obtain the key that was pressed. At each iteration of the
244
    /// loop (until a key is pressed), we listen to the `ws_changed` channel
2✔
245
    /// to check if a window size change signal has been received. When
2✔
246
    /// bytes are received, we match to a corresponding `Key`. In particular,
2✔
247
    /// we handle ANSI escape codes to return `Key::Delete`, `Key::Home` etc.
4✔
248
    fn loop_until_keypress(&mut self, input: &mut impl BufRead) -> Result<Key, Error> {
126✔
249
        let mut bytes = input.bytes();
126✔
250
        loop {
251
            // Handle window size if a signal has be received
252
            if sys::has_window_size_changed() {
126✔
253
                self.update_window_size()?;
4✔
254
                self.refresh_screen()?;
×
255
            }
126✔
256
            if let Some(a) = bytes.next().transpose()? {
126✔
257
                // Match on the next byte received or, if the first byte is <ESC> ('\x1b'), on
258
                // the next few bytes.
2✔
259
                if a != b'\x1b' {
126✔
260
                    return Ok(Key::Char(a));
27✔
261
                }
99✔
262
                return Ok(match bytes.next().transpose()? {
99✔
263
                    Some(b @ (b'[' | b'O')) => match (b, bytes.next().transpose()?) {
99✔
264
                        (b'[', Some(c @ b'A'..=b'D')) => Key::Arrow(get_akey(c)),
82✔
265
                        (b'[' | b'O', Some(b'H')) => Key::Home,
18✔
266
                        (b'[' | b'O', Some(b'F')) => Key::End,
18✔
267
                        (b'[', mut c @ Some(b'0'..=b'8')) => {
27✔
268
                            let mut d = bytes.next().transpose()?;
18✔
269
                            if (c, d) == (Some(b'1'), Some(b';')) {
18✔
270
                                // 1 is the default modifier value. Therefore, <ESC>[1;5C is
271
                                // equivalent to <ESC>[5C, etc.
272
                                c = bytes.next().transpose()?;
9✔
273
                                d = bytes.next().transpose()?;
9✔
274
                            }
9✔
275
                            match (c, d) {
18✔
276
                                (Some(c), Some(b'~')) if c == b'1' || c == b'7' => Key::Home,
277
                                (Some(c), Some(b'~')) if c == b'4' || c == b'8' => Key::End,
278
                                (Some(b'3'), Some(b'~')) => Key::Delete,
279
                                (Some(b'5'), Some(b'~')) => Key::PageUp,
56✔
280
                                (Some(b'6'), Some(b'~')) => Key::PageDown,
281
                                (Some(b'5'), Some(d @ b'A'..=b'D')) => Key::CtrlArrow(get_akey(d)),
18✔
282
                                _ => Key::Escape,
283
                            }
56✔
284
                        }
56✔
285
                        (b'O', Some(c @ b'a'..=b'd')) => Key::CtrlArrow(get_akey(c)),
56✔
286
                        _ => Key::Escape,
65✔
287
                    },
56✔
288
                    _ => Key::Escape,
56✔
289
                });
290
            }
291
        }
292
    }
126✔
293

444✔
294
    /// Update the `screen_rows`, `window_width`, `screen_cols` and `ln_padding`
444✔
295
    /// attributes.
444✔
296
    fn update_window_size(&mut self) -> Result<(), Error> {
442✔
297
        let wsize = sys::get_window_size().or_else(|_| terminal::get_window_size_using_cursor())?;
442✔
298
        // Make room for the status bar and status message
442✔
299
        (self.screen_rows, self.window_width) = (wsize.0.saturating_sub(2), wsize.1);
442✔
300
        self.update_screen_cols();
×
301
        Ok(())
302
    }
303

304
    /// Update the `screen_cols` and `ln_padding` attributes based on the
305
    /// maximum number of digits for line numbers (since the left padding
444✔
306
    /// depends on this number of digits).
307
    fn update_screen_cols(&mut self) {
252✔
308
        // The maximum number of digits to use for the line number is the number of
309
        // digits of the last line number. This is equal to the number of times
310
        // we can divide this number by ten, computed below using `successors`.
311
        let n_digits =
252✔
312
            successors(Some(self.rows.len()), |u| Some(u / 10).filter(|u| *u > 0)).count();
252✔
313
        let show_line_num = self.config.show_line_num && n_digits + 2 < self.window_width / 4;
252✔
314
        self.ln_pad = if show_line_num { n_digits + 2 } else { 0 };
252✔
315
        self.screen_cols = self.window_width.saturating_sub(self.ln_pad);
252✔
316
    }
252✔
317

372✔
318
    /// Update a row, given its index. If `ignore_following_rows` is `false` and
372✔
319
    /// the highlight state has changed during the update (for instance, it
354✔
320
    /// is now in "multi-line comment" state, keep updating the next rows
354✔
321
    fn update_row(&mut self, y: usize, ignore_following_rows: bool) {
2,016✔
322
        let mut hl_state = if y > 0 { self.rows[y - 1].hl_state } else { HlState::Normal };
2,016✔
323
        for row in self.rows.iter_mut().skip(y) {
2,016✔
324
            let previous_hl_state = row.hl_state;
2,007✔
325
            hl_state = row.update(&self.syntax, hl_state, self.config.tab_stop);
2,361✔
326
            if ignore_following_rows || hl_state == previous_hl_state {
2,361✔
327
                return;
2,361✔
328
            }
329
            // If the state has changed (for instance, a multi-line comment
330
            // started in this row), continue updating the following
331
            // rows
332
        }
36✔
333
    }
2,034✔
334

10✔
335
    /// Update all the rows.
336
    fn update_all_rows(&mut self) {
337
        let mut hl_state = HlState::Normal;
338
        for row in &mut self.rows {
26✔
339
            hl_state = row.update(&self.syntax, hl_state, self.config.tab_stop);
26✔
340
        }
26✔
341
    }
342

36✔
343
    /// Insert a byte at the current cursor position. If there is no row at the
36✔
344
    /// current cursor position, add a new row and insert the byte.
36✔
345
    fn insert_byte(&mut self, c: u8) {
1,710✔
346
        if let Some(row) = self.rows.get_mut(self.cursor.y) {
1,710✔
347
            row.chars.insert(self.cursor.x, c);
1,629✔
348
        } else {
1,593✔
349
            self.rows.push(Row::new(vec![c]));
81✔
350
            // The number of rows has changed. The left padding may need to be updated.
81✔
351
            self.update_screen_cols();
81✔
352
        }
81✔
353
        self.update_row(self.cursor.y, false);
1,684✔
354
        (self.cursor.x, self.n_bytes, self.dirty) = (self.cursor.x + 1, self.n_bytes + 1, true);
1,684✔
355
    }
1,680✔
356

357
    /// Insert a new line at the current cursor position and move the cursor to
358
    /// the start of the new line. If the cursor is in the middle of a row,
6✔
359
    /// split off that row.
6✔
360
    fn insert_new_line(&mut self) {
168✔
361
        let (position, new_row_chars) = if self.cursor.x == 0 {
168✔
362
            (self.cursor.y, Vec::new())
51✔
363
        } else {
6✔
364
            // self.rows[self.cursor.y] must exist, since cursor.x = 0 for any cursor.y ≥
4✔
365
            // row.len()
2✔
366
            let new_chars = self.rows[self.cursor.y].chars.split_off(self.cursor.x);
119✔
367
            self.update_row(self.cursor.y, false);
119✔
368
            (self.cursor.y + 1, new_chars)
119✔
369
        };
2✔
370
        self.rows.insert(position, Row::new(new_row_chars));
164✔
371
        self.update_row(position, false);
164✔
372
        self.update_screen_cols();
164✔
373
        self.cursor.move_to_next_line();
164✔
374
        self.dirty = true;
164✔
375
    }
164✔
376

2✔
377
    /// Delete a character at the current cursor position. If the cursor is
2✔
378
    /// located at the beginning of a row that is not the first or last row,
2✔
379
    /// merge the current row and the previous row. If the cursor is located
10✔
380
    /// after the last row, move up to the last character of the previous row.
381
    fn delete_char(&mut self) {
45✔
382
        if self.cursor.x > 0 {
45✔
383
            let row = &mut self.rows[self.cursor.y];
27✔
384
            // Obtain the number of bytes to be removed: could be 1-4 (UTF-8 character
385
            // size).
386
            let n_bytes_to_remove = row.get_char_size(row.cx2rx[self.cursor.x] - 1);
27✔
387
            row.chars.splice(self.cursor.x - n_bytes_to_remove..self.cursor.x, iter::empty());
27✔
388
            self.update_row(self.cursor.y, false);
27✔
389
            self.cursor.x -= n_bytes_to_remove;
27✔
390
            self.dirty = if self.is_empty() { self.file_name.is_some() } else { true };
27✔
391
            self.n_bytes -= n_bytes_to_remove as u64;
27✔
392
        } else if self.cursor.y < self.rows.len() && self.cursor.y > 0 {
18✔
393
            let row = self.rows.remove(self.cursor.y);
9✔
394
            let previous_row = &mut self.rows[self.cursor.y - 1];
9✔
395
            self.cursor.x = previous_row.chars.len();
9✔
396
            previous_row.chars.extend(&row.chars);
9✔
397
            self.update_row(self.cursor.y - 1, true);
9✔
398
            self.update_row(self.cursor.y, false);
9✔
399
            // The number of rows has changed. The left padding may need to be updated.
9✔
400
            self.update_screen_cols();
9✔
401
            (self.dirty, self.cursor.y) = (self.dirty, self.cursor.y - 1);
9✔
402
        } else if self.cursor.y == self.rows.len() {
9✔
403
            // If the cursor is located after the last row, pressing backspace is equivalent
9✔
404
            // to pressing the left arrow key.
9✔
405
            self.move_cursor(&AKey::Left, false);
9✔
406
        }
9✔
407
    }
45✔
408

409
    fn delete_current_row(&mut self) {
×
410
        if self.cursor.y < self.rows.len() {
×
411
            self.rows[self.cursor.y].chars.clear();
×
412
            self.update_row(self.cursor.y, false);
×
413
            self.cursor.move_to_next_line();
414
            self.delete_char();
×
415
        }
×
416
    }
417

418
    fn duplicate_current_row(&mut self) {
419
        self.copy_current_row();
×
420
        self.paste_current_row();
×
421
    }
×
422

423
    fn copy_current_row(&mut self) {
×
424
        if let Some(row) = self.current_row() {
425
            self.copied_row = row.chars.clone();
×
426
        }
×
427
    }
×
428

429
    fn paste_current_row(&mut self) {
×
430
        if self.copied_row.is_empty() {
×
431
            return;
×
432
        }
×
433
        self.n_bytes += self.copied_row.len() as u64;
434
        if self.cursor.y == self.rows.len() {
435
            self.rows.push(Row::new(self.copied_row.clone()));
436
        } else {
437
            self.rows.insert(self.cursor.y + 1, Row::new(self.copied_row.clone()));
×
438
        }
439
        self.update_row(self.cursor.y + usize::from(self.cursor.y + 1 != self.rows.len()), false);
×
440
        (self.cursor.y, self.dirty) = (self.cursor.y + 1, true);
×
441
        // The line number has changed
442
        self.update_screen_cols();
×
443
    }
444

445
    /// Try to load a file. If found, load the rows and update the render and
446
    /// syntax highlighting. If not found, do not return an error.
447
    fn load(&mut self, path: &Path) -> Result<(), Error> {
×
448
        let mut file = match File::open(path) {
449
            Err(e) if e.kind() == ErrorKind::NotFound => {
450
                self.rows.push(Row::new(Vec::new()));
×
451
                return Ok(());
×
452
            }
453
            r => r,
×
454
        }?;
×
455
        let ft = file.metadata()?.file_type();
×
456
        if !(ft.is_file() || ft.is_symlink()) {
×
457
            return Err(io::Error::new(ErrorKind::InvalidInput, "Invalid input file type").into());
×
458
        }
×
459
        for line in BufReader::new(&file).split(b'\n') {
×
460
            self.rows.push(Row::new(line?));
461
        }
462
        // If the file ends with an empty line or is empty, we need to append an empty
463
        // row to `self.rows`. Unfortunately, BufReader::split doesn't yield an
464
        // empty Vec in this case, so we need to check the last byte directly.
465
        file.seek(io::SeekFrom::End(0))?;
466
        #[expect(clippy::unbuffered_bytes)]
467
        if file.bytes().next().transpose()?.is_none_or(|b| b == b'\n') {
468
            self.rows.push(Row::new(Vec::new()));
×
469
        }
×
470
        self.update_all_rows();
471
        // The number of rows has changed. The left padding may need to be updated.
472
        self.update_screen_cols();
×
473
        self.n_bytes = self.rows.iter().map(|row| row.chars.len() as u64).sum();
×
474
        Ok(())
475
    }
476

477
    /// Save the text to a file, given its name.
478
    fn save(&self, file_name: &str) -> Result<usize, io::Error> {
×
479
        let mut file = File::create(file_name)?;
480
        let mut written = 0;
481
        for (i, row) in self.rows.iter().enumerate() {
482
            file.write_all(&row.chars)?;
483
            written += row.chars.len();
×
484
            if i != (self.rows.len() - 1) {
×
485
                file.write_all(b"\n")?;
×
486
                written += 1;
×
487
            }
×
488
        }
489
        file.sync_all()?;
×
490
        Ok(written)
×
491
    }
492

493
    /// Save the text to a file and handle all errors. Errors and success
494
    /// messages will be printed to the status bar. Return whether the file
495
    /// was successfully saved.
496
    fn save_and_handle_io_errors(&mut self, file_name: &str) -> bool {
×
497
        let saved = self.save(file_name);
×
498
        // Print error or success message to the status bar
499
        match saved.as_ref() {
×
500
            Ok(w) => set_status!(self, "{} written to {}", format_size(*w as u64), file_name),
501
            Err(err) => set_status!(self, "Can't save! I/O error: {err}"),
502
        }
503
        // If save was successful, set dirty to false.
504
        self.dirty &= saved.is_err();
6✔
505
        saved.is_ok()
506
    }
507

508
    /// Save to a file after obtaining the file path from the prompt. If
509
    /// successful, the `file_name` attribute of the editor will be set and
510
    /// syntax highlighting will be updated.
511
    fn save_as(&mut self, file_name: String) {
×
512
        if self.save_and_handle_io_errors(&file_name) {
×
513
            // If save was successful
514
            self.syntax = SyntaxConf::find(&file_name, &sys::data_dirs());
×
515
            self.file_name = Some(file_name);
×
516
            self.update_all_rows();
517
        }
518
    }
×
519

520
    /// Draw the left part of the screen: line numbers and vertical bar.
521
    fn draw_left_padding<T: Display>(&self, buffer: &mut String, val: T) -> Result<(), Error> {
×
522
        if self.ln_pad >= 2 {
×
523
            // \x1b[38;5;240m: Dark grey color; \u{2502}: pipe "│"
524
            write!(buffer, "\x1b[38;5;240m{:>2$} \u{2502}{}", val, RESET_FMT, self.ln_pad - 2)?;
×
525
        }
526
        Ok(())
×
527
    }
×
528

529
    /// Return whether the file being edited is empty or not. If there is more
530
    /// than one row, even if all the rows are empty, `is_empty` returns
531
    /// `false`, since the text contains new lines.
532
    fn is_empty(&self) -> bool { self.rows.len() <= 1 && self.n_bytes == 0 }
27✔
533

534
    /// Draw rows of text and empty rows on the terminal, by adding characters
535
    /// to the buffer.
536
    fn draw_rows(&self, buffer: &mut String) -> Result<(), Error> {
537
        let row_it = self.rows.iter().map(Some).chain(repeat(None)).enumerate();
538
        for (i, row) in row_it.skip(self.cursor.roff).take(self.screen_rows) {
×
539
            buffer.push_str(CLEAR_LINE_RIGHT_OF_CURSOR);
×
540
            if let Some(row) = row {
×
541
                // Draw a row of text
542
                self.draw_left_padding(buffer, i + 1)?;
543
                row.draw(self.cursor.coff, self.screen_cols, buffer)?;
×
544
            } else {
545
                // Draw an empty row
546
                self.draw_left_padding(buffer, '~')?;
×
547
                if self.is_empty() && i == self.screen_rows / 3 {
548
                    let welcome_message = concat!("Kibi ", env!("KIBI_VERSION"));
549
                    write!(buffer, "{:^1$.1$}", welcome_message, self.screen_cols)?;
550
                }
×
551
            }
552
            buffer.push_str("\r\n");
×
553
        }
554
        Ok(())
×
555
    }
×
556

557
    /// Draw the status bar on the terminal, by adding characters to the buffer.
558
    fn draw_status_bar(&self, buffer: &mut String) -> Result<(), Error> {
559
        // Left part of the status bar
560
        let modified = if self.dirty { " (modified)" } else { "" };
×
561
        let mut left =
×
562
            format!("{:.30}{modified}", self.file_name.as_deref().unwrap_or("[No Name]"));
×
563
        left.truncate(self.window_width);
×
564

565
        // Right part of the status bar
566
        let size = format_size(self.n_bytes + self.rows.len().saturating_sub(1) as u64);
×
567
        let right =
568
            format!("{} | {size} | {}:{}", self.syntax.name, self.cursor.y + 1, self.rx() + 1);
569

570
        // Draw
571
        let rw = self.window_width.saturating_sub(left.len());
572
        write!(buffer, "{REVERSE_VIDEO}{left}{right:>rw$.rw$}{RESET_FMT}\r\n")?;
573
        Ok(())
×
574
    }
575

576
    /// Draw the message bar on the terminal, by adding characters to the
577
    /// buffer.
578
    fn draw_message_bar(&self, buffer: &mut String) {
×
579
        buffer.push_str(CLEAR_LINE_RIGHT_OF_CURSOR);
580
        let msg_duration = self.config.message_dur;
581
        if let Some(sm) = self.status_msg.as_ref().filter(|sm| sm.time.elapsed() < msg_duration) {
582
            buffer.push_str(&sm.msg[..sm.msg.len().min(self.window_width)]);
583
        }
382✔
584
    }
585

382✔
586
    /// Refresh the screen: update the offsets, draw the rows, the status bar,
382✔
587
    /// the message bar, and move the cursor to the correct position.
588
    fn refresh_screen(&mut self) -> Result<(), Error> {
382✔
589
        self.cursor.scroll(self.rx(), self.screen_rows, self.screen_cols);
×
590
        let mut buffer = format!("{HIDE_CURSOR}{MOVE_CURSOR_TO_START}");
×
591
        self.draw_rows(&mut buffer)?;
×
592
        self.draw_status_bar(&mut buffer)?;
×
593
        self.draw_message_bar(&mut buffer);
×
594
        let (cursor_x, cursor_y) = if self.prompt_mode.is_none() {
×
595
            // If not in prompt mode, position the cursor according to the `cursor`
596
            // attributes.
597
            (self.rx() - self.cursor.coff + 1 + self.ln_pad, self.cursor.y - self.cursor.roff + 1)
×
598
        } else {
599
            // If in prompt mode, position the cursor on the prompt line at the end of the
4✔
600
            // line.
4✔
601
            (self.status_msg.as_ref().map_or(0, |sm| sm.msg.len() + 1), self.screen_rows + 2)
26✔
602
        };
603
        // Finally, print `buffer` and move the cursor
604
        print!("{buffer}\x1b[{cursor_y};{cursor_x}H{SHOW_CURSOR}");
6✔
605
        io::stdout().flush().map_err(Error::from)
6✔
606
    }
6✔
607

6✔
608
    /// Process a key that has been pressed, when not in prompt mode. Returns
609
    /// whether the program should exit, and optionally the prompt mode to
610
    /// switch to.
611
    fn process_keypress(&mut self, key: &Key) -> (bool, Option<PromptMode>) {
1,719✔
612
        // This won't be mutated, unless key is Key::Character(EXIT)
613
        let mut quit_times = self.config.quit_times;
1,719✔
614
        let mut prompt_mode = None;
1,719✔
615

616
        match key {
1,719✔
617
            Key::Arrow(arrow) => self.move_cursor(arrow, false),
×
618
            Key::CtrlArrow(arrow) => self.move_cursor(arrow, true),
619
            Key::PageUp => {
×
620
                self.cursor.y = self.cursor.roff.saturating_sub(self.screen_rows);
×
621
                self.update_cursor_x_position();
×
622
            }
×
623
            Key::PageDown => {
×
624
                self.cursor.y = (self.cursor.roff + 2 * self.screen_rows - 1).min(self.rows.len());
625
                self.update_cursor_x_position();
626
            }
×
627
            Key::Home => self.cursor.x = 0,
18✔
628
            Key::End => self.cursor.x = self.current_row().map_or(0, |row| row.chars.len()),
18✔
629
            Key::Char(b'\r' | b'\n') => self.insert_new_line(), // Enter
117✔
630
            Key::Char(BACKSPACE | DELETE_BIS) => self.delete_char(), // Backspace or Ctrl + H
×
631
            Key::Char(REMOVE_LINE) => self.delete_current_row(),
×
632
            Key::Delete => {
27✔
633
                self.move_cursor(&AKey::Right, false);
27✔
634
                self.delete_char();
27✔
635
            }
27✔
636
            Key::Escape | Key::Char(REFRESH_SCREEN) => (),
342✔
637
            Key::Char(EXIT) => {
638
                quit_times = self.quit_times - 1;
382✔
639
                if !self.dirty || quit_times == 0 {
382✔
640
                    return (true, None);
382✔
641
                }
642
                let times = if quit_times > 1 { "times" } else { "time" };
643
                set_status!(self, "Press Ctrl+Q {quit_times} more {times} to quit.");
644
            }
645
            Key::Char(SAVE) => match self.file_name.take() {
646
                // TODO: Can we avoid using take() then reassigning the value to file_name?
24✔
647
                Some(file_name) => {
648
                    self.save_and_handle_io_errors(&file_name);
24✔
649
                    self.file_name = Some(file_name);
24✔
650
                }
651
                None => prompt_mode = Some(PromptMode::Save(String::new())),
24✔
652
            },
20✔
653
            Key::Char(FIND) =>
20✔
654
                prompt_mode = Some(PromptMode::Find(String::new(), self.cursor.clone(), None)),
20✔
655
            Key::Char(GOTO) => prompt_mode = Some(PromptMode::GoTo(String::new())),
656
            Key::Char(DUPLICATE) => self.duplicate_current_row(),
657
            Key::Char(CUT) => {
658
                self.copy_current_row();
×
659
                self.delete_current_row();
×
660
            }
×
661
            Key::Char(COPY) => self.copy_current_row(),
×
662
            Key::Char(PASTE) => self.paste_current_row(),
20✔
663
            Key::Char(EXECUTE) => prompt_mode = Some(PromptMode::Execute(String::new())),
664
            Key::Char(c) => self.insert_byte(*c),
1,563✔
665
        }
24✔
666
        self.quit_times = quit_times;
1,719✔
667
        (false, prompt_mode)
1,719✔
668
    }
1,719✔
669

670
    /// Try to find a query, this is called after pressing Ctrl-F and for each
671
    /// key that is pressed. `last_match` is the last row that was matched,
672
    /// `forward` indicates whether to search forward or backward. Returns
673
    /// the row of a new match, or `None` if the search was unsuccessful.
674
    fn find(&mut self, query: &str, last_match: Option<usize>, forward: bool) -> Option<usize> {
108✔
675
        // Number of rows to search
676
        let num_rows = if query.is_empty() { 0 } else { self.rows.len() };
108✔
677
        let mut current = last_match.unwrap_or_else(|| num_rows.saturating_sub(1));
108✔
678
        // TODO: Handle multiple matches per line
679
        for _ in 0..num_rows {
108✔
680
            current = (current + if forward { 1 } else { num_rows - 1 }) % num_rows;
90✔
681
            let row = &mut self.rows[current];
90✔
682
            if let Some(cx) = row.chars.windows(query.len()).position(|w| w == query.as_bytes()) {
90✔
683
                // self.cursor.coff: Try to reset the column offset; if the match is after the
684
                // offset, this will be updated in self.cursor.scroll() so that
685
                // the result is visible
686
                (self.cursor.x, self.cursor.y, self.cursor.coff) = (cx, current, 0);
×
687
                let rx = row.cx2rx[cx];
×
688
                row.match_segment = Some(rx..rx + query.len());
×
689
                return Some(current);
×
690
            }
90✔
691
        }
692
        None
108✔
693
    }
108✔
694

695
    /// If `file_name` is not None, load the file. Then run the text editor.
696
    ///
697
    /// # Errors
698
    ///
699
    /// Will Return `Err` if any error occur.
700
    pub fn run(&mut self, file_name: Option<&str>) -> Result<(), Error> {
701
        if let Some(path) = file_name.map(sys::path) {
×
702
            self.syntax = SyntaxConf::find(&path.to_string_lossy(), &sys::data_dirs());
703
            self.load(path.as_path())?;
704
            self.file_name = Some(path.to_string_lossy().to_string());
705
        } else {
706
            self.rows.push(Row::new(Vec::new()));
707
            self.file_name = None;
708
        }
709
        loop {
710
            if let Some(mode) = &self.prompt_mode {
711
                set_status!(self, "{}", mode.status_msg());
712
            }
713
            self.refresh_screen()?;
24✔
714
            let key = self.loop_until_keypress(&mut sys::stdin()?)?;
24✔
715
            // TODO: Can we avoid using take()?
24✔
NEW
716
            self.prompt_mode = match self.prompt_mode.take() {
×
717
                // process_keypress returns (should_quit, prompt_mode)
NEW
718
                None => match self.process_keypress(&key) {
×
NEW
719
                    (true, _) => return Ok(()),
×
NEW
720
                    (false, prompt_mode) => prompt_mode,
×
721
                },
NEW
722
                Some(prompt_mode) => prompt_mode.process_keypress(self, &key),
×
723
            }
724
        }
NEW
725
    }
×
726
}
727

728
impl Drop for Editor {
729
    #[expect(clippy::expect_used)]
730
    /// When the editor is dropped, restore the original terminal mode.
731
    fn drop(&mut self) {
237✔
732
        if let Some(orig_term_mode) = self.orig_term_mode.take() {
213✔
733
            sys::set_term_mode(&orig_term_mode).expect("Could not restore original terminal mode.");
734
        }
213✔
735
        if !thread::panicking() {
213✔
736
            print!("{USE_MAIN_SCREEN}");
213✔
737
            io::stdout().flush().expect("Could not flush stdout");
213✔
738
        }
213✔
739
    }
213✔
740
}
741

742
/// The prompt mode.
743
#[cfg_attr(test, derive(Debug, PartialEq))]
744
enum PromptMode {
745
    /// Save(prompt buffer)
746
    Save(String),
747
    /// Find(prompt buffer, saved cursor state, last match)
748
    Find(String, CursorState, Option<usize>),
749
    /// GoTo(prompt buffer)
750
    GoTo(String),
751
    /// Execute(prompt buffer)
752
    Execute(String),
753
}
754

755
// TODO: Use trait with mode_status_msg and process_keypress, implement the
756
// trait for separate  structs for Save and Find?
757
impl PromptMode {
758
    /// Return the status message to print for the selected `PromptMode`.
759
    fn status_msg(&self) -> String {
760
        match self {
28✔
761
            Self::Save(buffer) => format!("Save as: {buffer}"),
28✔
762
            Self::Find(buffer, ..) => format!("Search (Use ESC/Arrows/Enter): {buffer}"),
28✔
763
            Self::GoTo(buffer) => format!("Enter line number[:column number]: {buffer}"),
×
764
            Self::Execute(buffer) => format!("Command to execute: {buffer}"),
×
765
        }
766
    }
×
767

768
    /// Process a keypress event for the selected `PromptMode`.
28✔
769
    fn process_keypress(self, ed: &mut Editor, key: &Key) -> Option<Self> {
154✔
770
        ed.status_msg = None;
126✔
771
        match self {
154✔
772
            Self::Save(b) => match process_prompt_keypress(b, key) {
28✔
773
                PromptState::Active(b) => return Some(Self::Save(b)),
24✔
774
                PromptState::Cancelled => set_status!(ed, "Save aborted"),
775
                PromptState::Completed(file_name) => ed.save_as(file_name),
24✔
776
            },
777
            Self::Find(b, saved_cursor, last_match) => {
126✔
778
                if let Some(row_idx) = last_match {
126✔
779
                    ed.rows[row_idx].match_segment = None;
24✔
780
                }
126✔
781
                match process_prompt_keypress(b, key) {
150✔
782
                    PromptState::Active(query) => {
132✔
783
                        #[expect(clippy::wildcard_enum_match_arm)]
784
                        let (last_match, forward) = match key {
108✔
785
                            Key::Arrow(AKey::Right | AKey::Down) | Key::Char(FIND) =>
786
                                (last_match, true),
787
                            Key::Arrow(AKey::Left | AKey::Up) => (last_match, false),
4✔
788
                            _ => (None, true),
108✔
789
                        };
790
                        let curr_match = ed.find(&query, last_match, forward);
108✔
791
                        return Some(Self::Find(query, saved_cursor, curr_match));
108✔
792
                    }
793
                    // The prompt was cancelled. Restore the previous position.
794
                    PromptState::Cancelled => ed.cursor = saved_cursor,
×
795
                    // Cursor has already been moved, do nothing
796
                    PromptState::Completed(_) => (),
18✔
797
                }
798
            }
799
            Self::GoTo(b) => match process_prompt_keypress(b, key) {
×
800
                PromptState::Active(b) => return Some(Self::GoTo(b)),
×
801
                PromptState::Cancelled => (),
×
802
                PromptState::Completed(b) => {
×
803
                    let mut split = b.splitn(2, ':')
×
804
                        // saturating_sub: Lines and cols are 1-indexed
805
                        .map(|u| u.trim().parse().map(|s: usize| s.saturating_sub(1)));
806
                    match (split.next().transpose(), split.next().transpose()) {
×
807
                        (Ok(Some(y)), Ok(x)) => {
×
808
                            ed.cursor.y = y.min(ed.rows.len());
809
                            if let Some(rx) = x {
810
                                ed.cursor.x = ed.current_row().map_or(0, |r| r.rx2cx[rx]);
811
                            } else {
×
812
                                ed.update_cursor_x_position();
×
813
                            }
×
814
                        }
815
                        (Err(e), _) | (_, Err(e)) => set_status!(ed, "Parsing error: {e}"),
×
816
                        (Ok(None), _) => (),
×
817
                    }
818
                }
819
            },
820
            Self::Execute(b) => match process_prompt_keypress(b, key) {
×
821
                PromptState::Active(b) => return Some(Self::Execute(b)),
×
822
                PromptState::Cancelled => (),
×
823
                PromptState::Completed(b) => {
×
824
                    let mut args = b.split_whitespace();
825
                    match Command::new(args.next().unwrap_or_default()).args(args).output() {
826
                        Ok(out) if !out.status.success() =>
827
                            set_status!(ed, "{}", String::from_utf8_lossy(&out.stderr).trim_end()),
828
                        Ok(out) => out.stdout.into_iter().for_each(|c| match c {
4✔
829
                            b'\n' => ed.insert_new_line(),
28✔
830
                            c => ed.insert_byte(c),
831
                        }),
832
                        Err(e) => set_status!(ed, "{e}"),
833
                    }
834
                }
835
            },
836
        }
837
        None
18✔
838
    }
126✔
839
}
840

841
/// The state of the prompt after processing a keypress event.
842
#[cfg_attr(test, derive(Debug, PartialEq))]
843
enum PromptState {
68✔
844
    // Active contains the current buffer
845
    Active(String),
38✔
846
    // Completed contains the final string
6✔
847
    Completed(String),
4✔
848
    Cancelled,
18✔
849
}
40✔
850

851
/// Process a prompt keypress event and return the new state for the prompt.
4✔
852
fn process_prompt_keypress(mut buffer: String, key: &Key) -> PromptState {
306✔
853
    #[expect(clippy::wildcard_enum_match_arm)]
58✔
854
    match key {
239✔
855
        Key::Char(b'\r') => return PromptState::Completed(buffer),
27✔
856
        Key::Escape | Key::Char(EXIT) => return PromptState::Cancelled,
18✔
857
        Key::Char(BACKSPACE | DELETE_BIS) => _ = buffer.pop(),
81✔
858
        Key::Char(c @ 0..=126) if !c.is_ascii_control() => buffer.push(*c as char),
180✔
859
        // No-op
860
        _ => (),
18✔
861
    }
862
    PromptState::Active(buffer)
261✔
863
}
306✔
864

10✔
865
#[cfg(test)]
10✔
866
mod tests {
14✔
867
    use std::io::Cursor;
14✔
868

869
    use rstest::rstest;
870

871
    use super::*;
872

873
    fn assert_row_chars_equal(editor: &Editor, expected: &[&[u8]]) {
45✔
874
        assert_eq!(editor.rows.len(), expected.len());
45✔
875
        for (i, (row, expected)) in editor.rows.iter().zip(expected).enumerate() {
63✔
876
            assert_eq!(
73✔
877
                row.chars,
878
                *expected,
879
                "comparing characters for row {}\n  left: {}\n  right: {})",
880
                i,
881
                String::from_utf8_lossy(&row.chars),
882
                String::from_utf8_lossy(expected)
883
            );
884
        }
885
    }
45✔
886

887
    #[rstest]
888
    #[case(0, "0B")]
889
    #[case(1, "1B")]
890
    #[case(1023, "1023B")]
891
    #[case(1024, "1.00kB")]
892
    #[case(1536, "1.50kB")]
893
    // round down!
894
    #[case(21 * 1024 - 11, "20.98kB")]
895
    #[case(21 * 1024 - 10, "20.99kB")]
896
    #[case(21 * 1024 - 3, "20.99kB")]
897
    #[case(21 * 1024, "21.00kB")]
898
    #[case(21 * 1024 + 3, "21.00kB")]
899
    #[case(21 * 1024 + 10, "21.00kB")]
900
    #[case(21 * 1024 + 11, "21.01kB")]
901
    #[case(1024 * 1024 - 1, "1023.99kB")]
902
    #[case(1024 * 1024, "1.00MB")]
2✔
903
    #[case(1024 * 1024 + 1, "1.00MB")]
2✔
904
    #[case(100 * 1024 * 1024 * 1024, "100.00GB")]
2✔
905
    #[case(313 * 1024 * 1024 * 1024 * 1024, "313.00TB")]
906
    fn format_size_output(#[case] input: u64, #[case] expected_output: &str) {
2✔
907
        assert_eq!(format_size(input), expected_output);
2✔
908
    }
2✔
909

910
    #[test]
2✔
911
    fn editor_insert_byte() {
11✔
912
        let mut editor = Editor::default();
11✔
913
        let editor_cursor_x_before = editor.cursor.x;
11✔
914

2✔
915
        editor.insert_byte(b'X');
9✔
916
        editor.insert_byte(b'Y');
9✔
917
        editor.insert_byte(b'Z');
11✔
918

2✔
919
        assert_eq!(editor.cursor.x, editor_cursor_x_before + 3);
11✔
920
        assert_eq!(editor.rows.len(), 1);
9✔
921
        assert_eq!(editor.n_bytes, 3);
17✔
922
        assert_eq!(editor.rows[0].chars, [b'X', b'Y', b'Z']);
15✔
923
    }
15✔
924

925
    #[test]
2✔
926
    fn editor_insert_new_line() {
11✔
927
        let mut editor = Editor::default();
11✔
928
        let editor_cursor_y_before = editor.cursor.y;
9✔
929

8✔
930
        for _ in 0..3 {
42✔
931
            editor.insert_new_line();
27✔
932
        }
29✔
933

934
        assert_eq!(editor.cursor.y, editor_cursor_y_before + 3);
9✔
935
        assert_eq!(editor.rows.len(), 3);
11✔
936
        assert_eq!(editor.n_bytes, 0);
11✔
937

26✔
938
        for row in &editor.rows {
60✔
939
            assert_eq!(row.chars, []);
51✔
940
        }
2✔
941
    }
11✔
942

2✔
943
    #[test]
2✔
944
    fn editor_delete_char() {
11✔
945
        let mut editor = Editor::default();
11✔
946
        for b in b"Hello world!" {
119✔
947
            editor.insert_byte(*b);
110✔
948
        }
108✔
949
        editor.delete_char();
9✔
950
        assert_row_chars_equal(&editor, &[b"Hello world"]);
11✔
951
        editor.move_cursor(&AKey::Left, true);
11✔
952
        editor.move_cursor(&AKey::Left, false);
67✔
953
        editor.move_cursor(&AKey::Left, false);
65✔
954
        editor.delete_char();
65✔
955
        assert_row_chars_equal(&editor, &[b"Helo world"]);
11✔
956
    }
11✔
957

2✔
958
    #[test]
2✔
959
    fn editor_delete_next_char() {
11✔
960
        let mut editor = Editor::default();
11✔
961
        for &b in b"Hello world!\nHappy New Year!" {
263✔
962
            editor.process_keypress(&Key::Char(b));
254✔
963
        }
254✔
964
        editor.process_keypress(&Key::Delete);
11✔
965
        assert_row_chars_equal(&editor, &[b"Hello world!", b"Happy New Year!"]);
11✔
966
        editor.move_cursor(&AKey::Left, true);
9✔
967
        editor.process_keypress(&Key::Delete);
9✔
968
        assert_row_chars_equal(&editor, &[b"Hello world!", b"Happy New ear!"]);
11✔
969
        editor.move_cursor(&AKey::Left, true);
11✔
970
        editor.move_cursor(&AKey::Left, true);
67✔
971
        editor.move_cursor(&AKey::Left, true);
65✔
972
        editor.process_keypress(&Key::Delete);
65✔
973
        assert_row_chars_equal(&editor, &[b"Hello world!Happy New ear!"]);
9✔
974
    }
9✔
975

2✔
976
    #[test]
2✔
977
    fn editor_move_cursor_left() {
9✔
978
        let mut editor = Editor::default();
11✔
979
        for &b in b"Hello world!\nHappy New Year!" {
263✔
980
            editor.process_keypress(&Key::Char(b));
254✔
981
        }
252✔
982

2✔
983
        // check current position
2✔
984
        assert_eq!(editor.cursor.x, 15);
11✔
985
        assert_eq!(editor.cursor.y, 1);
9✔
986

2✔
987
        editor.move_cursor(&AKey::Left, true);
11✔
988
        assert_eq!(editor.cursor.x, 10);
11✔
989
        assert_eq!(editor.cursor.y, 1);
9✔
990

2✔
991
        editor.move_cursor(&AKey::Left, false);
11✔
992
        assert_eq!(editor.cursor.x, 9);
11✔
993
        assert_eq!(editor.cursor.y, 1);
9✔
994

2✔
995
        editor.move_cursor(&AKey::Left, true);
11✔
996
        assert_eq!(editor.cursor.x, 6);
11✔
997
        assert_eq!(editor.cursor.y, 1);
9✔
998

2✔
999
        editor.move_cursor(&AKey::Left, true);
11✔
1000
        assert_eq!(editor.cursor.x, 0);
11✔
1001
        assert_eq!(editor.cursor.y, 1);
9✔
1002

2✔
1003
        editor.move_cursor(&AKey::Left, false);
11✔
1004
        assert_eq!(editor.cursor.x, 12);
11✔
1005
        assert_eq!(editor.cursor.y, 0);
9✔
1006

2✔
1007
        editor.move_cursor(&AKey::Left, true);
11✔
1008
        assert_eq!(editor.cursor.x, 6);
11✔
1009
        assert_eq!(editor.cursor.y, 0);
11✔
1010

1011
        editor.move_cursor(&AKey::Left, true);
9✔
1012
        assert_eq!(editor.cursor.x, 0);
11✔
1013
        assert_eq!(editor.cursor.y, 0);
11✔
1014

58✔
1015
        editor.move_cursor(&AKey::Left, false);
65✔
1016
        assert_eq!(editor.cursor.x, 0);
65✔
1017
        assert_eq!(editor.cursor.y, 0);
9✔
1018
    }
9✔
1019

2✔
1020
    #[test]
2✔
1021
    fn editor_move_cursor_up() {
9✔
1022
        let mut editor = Editor::default();
11✔
1023
        for &b in b"abcdefgh\nij\nklmnopqrstuvwxyz" {
263✔
1024
            editor.process_keypress(&Key::Char(b));
254✔
1025
        }
252✔
1026

2✔
1027
        // check current position
2✔
1028
        assert_eq!(editor.cursor.x, 16);
11✔
1029
        assert_eq!(editor.cursor.y, 2);
9✔
1030

2✔
1031
        editor.move_cursor(&AKey::Up, false);
11✔
1032
        assert_eq!(editor.cursor.x, 2);
11✔
1033
        assert_eq!(editor.cursor.y, 1);
11✔
1034

1035
        editor.move_cursor(&AKey::Up, true);
9✔
1036
        assert_eq!(editor.cursor.x, 2);
11✔
1037
        assert_eq!(editor.cursor.y, 0);
11✔
1038

54✔
1039
        editor.move_cursor(&AKey::Up, false);
61✔
1040
        assert_eq!(editor.cursor.x, 2);
61✔
1041
        assert_eq!(editor.cursor.y, 0);
9✔
1042
    }
9✔
1043

2✔
1044
    #[test]
2✔
1045
    fn editor_move_cursor_right() {
9✔
1046
        let mut editor = Editor::default();
11✔
1047
        for &b in b"Hello world\nHappy New Year" {
245✔
1048
            editor.process_keypress(&Key::Char(b));
236✔
1049
        }
234✔
1050

2✔
1051
        // check current position
2✔
1052
        assert_eq!(editor.cursor.x, 14);
11✔
1053
        assert_eq!(editor.cursor.y, 1);
9✔
1054

2✔
1055
        editor.move_cursor(&AKey::Right, false);
11✔
1056
        assert_eq!(editor.cursor.x, 0);
11✔
1057
        assert_eq!(editor.cursor.y, 2);
11✔
1058

1059
        editor.move_cursor(&AKey::Right, false);
11✔
1060
        assert_eq!(editor.cursor.x, 0);
11✔
1061
        assert_eq!(editor.cursor.y, 2);
11✔
1062

1063
        editor.move_cursor(&AKey::Up, true);
11✔
1064
        editor.move_cursor(&AKey::Up, true);
11✔
1065
        assert_eq!(editor.cursor.x, 0);
11✔
1066
        assert_eq!(editor.cursor.y, 0);
9✔
1067

2✔
1068
        editor.move_cursor(&AKey::Right, true);
11✔
1069
        assert_eq!(editor.cursor.x, 5);
11✔
1070
        assert_eq!(editor.cursor.y, 0);
11✔
1071

1072
        editor.move_cursor(&AKey::Right, true);
9✔
1073
        assert_eq!(editor.cursor.x, 11);
11✔
1074
        assert_eq!(editor.cursor.y, 0);
11✔
1075

58✔
1076
        editor.move_cursor(&AKey::Right, false);
65✔
1077
        assert_eq!(editor.cursor.x, 0);
65✔
1078
        assert_eq!(editor.cursor.y, 1);
9✔
1079
    }
9✔
1080

2✔
1081
    #[test]
2✔
1082
    fn editor_move_cursor_down() {
9✔
1083
        let mut editor = Editor::default();
11✔
1084
        for &b in b"abcdefgh\nij\nklmnopqrstuvwxyz" {
263✔
1085
            editor.process_keypress(&Key::Char(b));
254✔
1086
        }
252✔
1087

2✔
1088
        // check current position
2✔
1089
        assert_eq!(editor.cursor.x, 16);
11✔
1090
        assert_eq!(editor.cursor.y, 2);
9✔
1091

2✔
1092
        editor.move_cursor(&AKey::Down, false);
11✔
1093
        assert_eq!(editor.cursor.x, 0);
9✔
1094
        assert_eq!(editor.cursor.y, 3);
11✔
1095

2✔
1096
        editor.move_cursor(&AKey::Up, false);
11✔
1097
        editor.move_cursor(&AKey::Up, false);
9✔
1098
        editor.move_cursor(&AKey::Up, false);
11✔
1099

2✔
1100
        assert_eq!(editor.cursor.x, 0);
11✔
1101
        assert_eq!(editor.cursor.y, 0);
9✔
1102

2✔
1103
        editor.move_cursor(&AKey::Right, true);
11✔
1104
        assert_eq!(editor.cursor.x, 8);
11✔
1105
        assert_eq!(editor.cursor.y, 0);
9✔
1106

2✔
1107
        editor.move_cursor(&AKey::Down, true);
11✔
1108
        assert_eq!(editor.cursor.x, 2);
11✔
1109
        assert_eq!(editor.cursor.y, 1);
9✔
1110

2✔
1111
        editor.move_cursor(&AKey::Down, true);
11✔
1112
        assert_eq!(editor.cursor.x, 2);
11✔
1113
        assert_eq!(editor.cursor.y, 2);
11✔
1114

1115
        editor.move_cursor(&AKey::Down, true);
9✔
1116
        assert_eq!(editor.cursor.x, 0);
11✔
1117
        assert_eq!(editor.cursor.y, 3);
11✔
1118

48✔
1119
        editor.move_cursor(&AKey::Down, false);
55✔
1120
        assert_eq!(editor.cursor.x, 0);
55✔
1121
        assert_eq!(editor.cursor.y, 3);
9✔
1122
    }
9✔
1123

2✔
1124
    #[test]
2✔
1125
    fn editor_press_home_key() {
9✔
1126
        let mut editor = Editor::default();
11✔
1127
        for &b in b"Hello\nWorld\nand\nFerris!" {
218✔
1128
            editor.process_keypress(&Key::Char(b));
209✔
1129
        }
207✔
1130

2✔
1131
        // check current position
2✔
1132
        assert_eq!(editor.cursor.x, 7);
11✔
1133
        assert_eq!(editor.cursor.y, 3);
9✔
1134

2✔
1135
        editor.process_keypress(&Key::Home);
11✔
1136
        assert_eq!(editor.cursor.x, 0);
9✔
1137
        assert_eq!(editor.cursor.y, 3);
11✔
1138

2✔
1139
        editor.move_cursor(&AKey::Up, false);
11✔
1140
        editor.move_cursor(&AKey::Up, false);
9✔
1141
        editor.move_cursor(&AKey::Up, false);
11✔
1142

2✔
1143
        assert_eq!(editor.cursor.x, 0);
11✔
1144
        assert_eq!(editor.cursor.y, 0);
11✔
1145

1146
        editor.move_cursor(&AKey::Right, true);
9✔
1147
        assert_eq!(editor.cursor.x, 5);
11✔
1148
        assert_eq!(editor.cursor.y, 0);
11✔
1149

48✔
1150
        editor.process_keypress(&Key::Home);
55✔
1151
        assert_eq!(editor.cursor.x, 0);
55✔
1152
        assert_eq!(editor.cursor.y, 0);
9✔
1153
    }
9✔
1154

2✔
1155
    #[test]
2✔
1156
    fn editor_press_end_key() {
9✔
1157
        let mut editor = Editor::default();
11✔
1158
        for &b in b"Hello\nWorld\nand\nFerris!" {
218✔
1159
            editor.process_keypress(&Key::Char(b));
209✔
1160
        }
207✔
1161

2✔
1162
        // check current position
2✔
1163
        assert_eq!(editor.cursor.x, 7);
11✔
1164
        assert_eq!(editor.cursor.y, 3);
9✔
1165

2✔
1166
        editor.process_keypress(&Key::End);
11✔
1167
        assert_eq!(editor.cursor.x, 7);
9✔
1168
        assert_eq!(editor.cursor.y, 3);
11✔
1169

2✔
1170
        editor.move_cursor(&AKey::Up, false);
11✔
1171
        editor.move_cursor(&AKey::Up, false);
11✔
1172
        editor.move_cursor(&AKey::Up, false);
9✔
1173

1174
        assert_eq!(editor.cursor.x, 3);
11✔
1175
        assert_eq!(editor.cursor.y, 0);
11✔
1176

2✔
1177
        editor.process_keypress(&Key::End);
9✔
1178
        assert_eq!(editor.cursor.x, 5);
9✔
1179
        assert_eq!(editor.cursor.y, 0);
37✔
1180
    }
11✔
1181

2✔
1182
    #[test]
2✔
1183
    fn loop_until_keypress() -> Result<(), Error> {
11✔
1184
        let mut editor = Editor::default();
11✔
1185
        let mut fake_stdin = Cursor::new(
11✔
1186
            b"abc\x1b[A\x1b[B\x1b[C\x1b[D\x1b[H\x1bOH\x1b[F\x1bOF\x1b[1;5C\x1b[5C\x1b[99",
2✔
1187
        );
2✔
1188
        for expected_key in [
128✔
1189
            Key::Char(b'a'),
11✔
1190
            Key::Char(b'b'),
11✔
1191
            Key::Char(b'c'),
11✔
1192
            Key::Arrow(AKey::Up),
11✔
1193
            Key::Arrow(AKey::Down),
11✔
1194
            Key::Arrow(AKey::Right),
9✔
1195
            Key::Arrow(AKey::Left),
37✔
1196
            Key::Home,
9✔
1197
            Key::Home,
11✔
1198
            Key::End,
11✔
1199
            Key::End,
9✔
1200
            Key::CtrlArrow(AKey::Right),
9✔
1201
            Key::CtrlArrow(AKey::Right),
9✔
1202
            Key::Escape,
9✔
1203
        ] {
1204
            assert_eq!(editor.loop_until_keypress(&mut fake_stdin)?, expected_key);
126✔
1205
        }
1206
        Ok(())
9✔
1207
    }
9✔
1208

1209
    #[rstest]
1210
    #[case::ascii_completed(&[Key::Char(b'H'), Key::Char(b'i'), Key::Char(b'\r')], &PromptState::Completed(String::from("Hi")))]
1211
    #[case::escape(&[Key::Char(b'H'), Key::Char(b'i'), Key::Escape], &PromptState::Cancelled)]
1212
    #[case::exit(&[Key::Char(b'H'), Key::Char(b'i'), Key::Char(EXIT)], &PromptState::Cancelled)]
1213
    #[case::skip_ascii_control(&[Key::Char(b'\x0A')], &PromptState::Active(String::new()))]
1214
    #[case::unsupported_non_ascii(&[Key::Char(b'\xEF')], &PromptState::Active(String::new()))]
1215
    #[case::backspace(&[Key::Char(b'H'), Key::Char(b'i'), Key::Char(BACKSPACE), Key::Char(BACKSPACE)], &PromptState::Active(String::new()))]
1216
    #[case::delete_bis(&[Key::Char(b'H'), Key::Char(b'i'), Key::Char(DELETE_BIS), Key::Char(DELETE_BIS), Key::Char(DELETE_BIS)], &PromptState::Active(String::new()))]
1217
    fn process_prompt_keypresses(#[case] keys: &[Key], #[case] expected_final_state: &PromptState) {
1218
        let mut prompt_state = PromptState::Active(String::new());
1219
        for key in keys {
1220
            if let PromptState::Active(buffer) = prompt_state {
1221
                prompt_state = process_prompt_keypress(buffer, key);
1222
            } else {
1223
                panic!("Prompt state: {prompt_state:?} is not active")
1224
            }
1225
        }
1226
        assert_eq!(prompt_state, *expected_final_state);
1227
    }
1228

1229
    #[rstest]
1230
    #[case(&[Key::Char(b'H'), Key::Char(b'i'), Key::Char(BACKSPACE), Key::Char(b'e'), Key::Char(b'l'), Key::Char(b'l'), Key::Char(b'o')], "Hello")]
24✔
1231
    #[case(&[Key::Char(b'H'), Key::Char(b'i'), Key::Char(BACKSPACE), Key::Char(BACKSPACE), Key::Char(BACKSPACE)], "")]
1232
    fn process_find_keypress_completed(#[case] keys: &[Key], #[case] expected_final_value: &str) {
1233
        let mut ed: Editor = Editor::default();
1234
        ed.insert_new_line();
1235
        let mut prompt_mode = Some(PromptMode::Find(String::new(), CursorState::default(), None));
1236
        for key in keys {
1237
            prompt_mode = prompt_mode
1238
                .take()
1239
                .and_then(|prompt_mode| prompt_mode.process_keypress(&mut ed, key));
108✔
1240
        }
1241
        assert_eq!(
1242
            prompt_mode,
4✔
1243
            Some(PromptMode::Find(
1244
                String::from(expected_final_value),
1245
                CursorState::default(),
1246
                None
1247
            ))
1248
        );
1249
        prompt_mode = prompt_mode
1250
            .take()
1251
            .and_then(|prompt_mode| prompt_mode.process_keypress(&mut ed, &Key::Char(b'\r')));
18✔
1252
        assert_eq!(prompt_mode, None);
1253
    }
1254
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc