Skip to main content

endbasic_repl/
editor.rs

1// EndBASIC
2// Copyright 2020 Julio Merino
3//
4// Licensed under the Apache License, Version 2.0 (the "License"); you may not
5// use this file except in compliance with the License.  You may obtain a copy
6// of the License at:
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
13// License for the specific language governing permissions and limitations
14// under the License.
15
16//! Interactive console-based text editor.
17
18use crate::console::{CharsXY, ClearType, Console, Key};
19use async_trait::async_trait;
20use endbasic_std::console::{AnsiColor, LineBuffer};
21use endbasic_std::program::Program;
22use std::cmp;
23use std::convert::TryFrom;
24use std::io;
25
26/// The color of the main editor window.
27const TEXT_COLOR: (Option<u8>, Option<u8>) = (Some(AnsiColor::White as u8), None);
28
29/// The color of the editor status bar.
30const STATUS_COLOR: (Option<u8>, Option<u8>) =
31    (Some(AnsiColor::BrightWhite as u8), Some(AnsiColor::Blue as u8));
32
33/// Default indentation with.
34const INDENT_WIDTH: usize = 4;
35
36/// Keybindings cheat sheet.
37const KEYS_SUMMARY: &str = " ESC Exit ";
38
39/// Returns the indentation of a given string as a new string.
40fn copy_indent(line: &LineBuffer) -> String {
41    let mut indent = String::new();
42    for ch in line.chars() {
43        if !ch.is_whitespace() {
44            break;
45        }
46        indent.push(ch);
47    }
48    indent
49}
50
51/// Finds the first position within the line that is not an indentation character, or returns
52/// the line length if no such character is found.
53fn find_indent_end(line: &LineBuffer) -> usize {
54    let mut pos = 0;
55    for ch in line.chars() {
56        if ch != ' ' {
57            break;
58        }
59        pos += 1;
60    }
61    debug_assert!(pos <= line.len());
62    pos
63}
64
65/// Represents a position within a file.
66#[derive(Clone, Copy, Default)]
67struct FilePos {
68    /// The column number, starting from zero.
69    line: usize,
70
71    /// The row number, starting from zero.
72    col: usize,
73}
74
75/// Describes how much of the editor view must be repainted.
76///
77/// Order is important!  Later entries in this enum mean that "more" of the viewport must
78/// be refreshed.
79#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
80enum RefreshKind {
81    None,
82    CurrentLine,
83    Full,
84}
85
86impl RefreshKind {
87    /// Upgrades `self` to at least `new_value`.
88    fn upgrade(&mut self, new_value: Self) {
89        if new_value > *self {
90            *self = new_value;
91        }
92    }
93}
94
95/// An interactive console-based text editor.
96///
97/// The text editor owns the textual contents it is editing.
98pub struct Editor {
99    /// Path of the loaded program.  `None` if the program has never been saved yet.
100    name: Option<String>,
101
102    /// Owned contents of the file being edited.
103    content: Vec<LineBuffer>,
104
105    /// Whether the `content` was modified since it was last retrieved.
106    dirty: bool,
107
108    /// Position of the top-left character of the file visible in the console.
109    viewport_pos: FilePos,
110
111    /// Insertion position within the file.
112    file_pos: FilePos,
113
114    /// Last edited column, used when moving vertically to preserve the insertion point even when
115    /// traversing shorter lines.
116    insert_col: usize,
117}
118
119impl Default for Editor {
120    /// Creates a new editor without any stored contents.
121    fn default() -> Self {
122        Self {
123            name: None,
124            content: vec![],
125            dirty: false,
126            viewport_pos: FilePos::default(),
127            file_pos: FilePos::default(),
128            insert_col: 0,
129        }
130    }
131}
132
133impl Editor {
134    /// Appends the line at `line + 1` to the line at `line`.
135    fn join_next_line_into(&mut self, line: usize) {
136        let next = self.content.remove(line + 1);
137        self.content[line].push_str(&next);
138        self.dirty = true;
139    }
140
141    /// Rewrites the status line at the bottom of the `console`, using the previously queried
142    /// `console_size`.
143    ///
144    /// It is the responsibility of the caller to move the cursor back to the appropriate location
145    /// after calling this function, and the caller should also hide the cursor before calling this
146    /// function.
147    fn refresh_status(&self, console: &mut dyn Console, console_size: CharsXY) -> io::Result<()> {
148        // Even though we track file positions as 0-indexed, display them as 1-indexed for a better
149        // user experience given that this is what all other editor seem to do.
150        let dirty_marker = if self.dirty { "*" } else { "" };
151        let long_details = format!(
152            " | {}{} | Ln {}, Col {} ",
153            self.name.as_deref().unwrap_or("<NO NAME>"),
154            dirty_marker,
155            self.file_pos.line + 1,
156            self.file_pos.col + 1
157        );
158
159        let width = usize::from(console_size.x);
160        let mut status = String::with_capacity(width);
161        if KEYS_SUMMARY.len() + long_details.len() >= width {
162            let short_details = format!(" {}:{} ", self.file_pos.line + 1, self.file_pos.col + 1);
163            if short_details.len() < width {
164                while status.len() < width - short_details.len() {
165                    status.push(' ');
166                }
167            }
168            status.push_str(&short_details);
169        } else {
170            status.push_str(KEYS_SUMMARY);
171            while status.len() < width - long_details.len() {
172                status.push(' ');
173            }
174            status.push_str(&long_details);
175        }
176        status.truncate(width);
177
178        console.locate(CharsXY::new(0, console_size.y - 1))?;
179        console.set_color(STATUS_COLOR.0, STATUS_COLOR.1)?;
180        console.write(&status)?;
181        Ok(())
182    }
183
184    /// Refreshes the contents of the whole `console`, using the previously queried `console_size`.
185    ///
186    /// It is the responsibility of the caller to move the cursor back to the appropriate location
187    /// after calling this function, and the caller should also hide the cursor before calling this
188    /// function.
189    fn refresh(&self, console: &mut dyn Console, console_size: CharsXY) -> io::Result<()> {
190        console.set_color(TEXT_COLOR.0, TEXT_COLOR.1)?;
191        console.clear(ClearType::All)?;
192        self.refresh_status(console, console_size)?;
193        console.set_color(TEXT_COLOR.0, TEXT_COLOR.1)?;
194        console.locate(CharsXY::default())?;
195
196        let mut row = self.viewport_pos.line;
197        let mut printed_rows = 0;
198        while row < self.content.len() && printed_rows < console_size.y - 1 {
199            let line = &self.content[row];
200            let line_len = line.len();
201            if line_len > self.viewport_pos.col {
202                console.print(&line.range(
203                    self.viewport_pos.col,
204                    self.viewport_pos.col + usize::from(console_size.x),
205                ))?;
206            } else {
207                console.print("")?;
208            }
209            row += 1;
210            printed_rows += 1;
211        }
212        Ok(())
213    }
214
215    /// Refreshes only the line under the cursor, using the previously queried `console_size`.
216    fn refresh_current_line(
217        &self,
218        console: &mut dyn Console,
219        console_size: CharsXY,
220    ) -> io::Result<()> {
221        debug_assert!(self.file_pos.line >= self.viewport_pos.line);
222        debug_assert!(
223            self.file_pos.line < self.viewport_pos.line + usize::from(console_size.y - 1)
224        );
225
226        let row = self.file_pos.line - self.viewport_pos.line;
227        let row = if cfg!(debug_assertions) {
228            u16::try_from(row).expect("Computed y must have fit on screen")
229        } else {
230            row as u16
231        };
232
233        console.set_color(TEXT_COLOR.0, TEXT_COLOR.1)?;
234        console.locate(CharsXY::new(0, row))?;
235        console.clear(ClearType::CurrentLine)?;
236
237        let line = &self.content[self.file_pos.line];
238        if line.len() > self.viewport_pos.col {
239            console.write(&line.range(
240                self.viewport_pos.col,
241                self.viewport_pos.col + usize::from(console_size.x),
242            ))?;
243        }
244        Ok(())
245    }
246
247    /// Moves the cursor down by the given number of lines in `nlines` or to the last line if there
248    /// are insufficient lines to perform the move.
249    fn move_down(&mut self, nlines: usize) {
250        if self.file_pos.line + nlines < self.content.len() {
251            self.file_pos.line += nlines;
252        } else {
253            self.file_pos.line = self.content.len() - 1;
254        }
255
256        let line = &self.content[self.file_pos.line];
257        self.file_pos.col = cmp::min(self.insert_col, line.len());
258    }
259
260    /// Moves the cursor up by the given number of lines in `nlines` or to the first line if there
261    /// are insufficient lines to perform the move.
262    fn move_up(&mut self, nlines: usize) {
263        if self.file_pos.line > nlines {
264            self.file_pos.line -= nlines;
265        } else {
266            self.file_pos.line = 0;
267        }
268
269        let line = &self.content[self.file_pos.line];
270        self.file_pos.col = cmp::min(self.insert_col, line.len());
271    }
272
273    /// Internal implementation of the interactive editor, which interacts with the `console`.
274    async fn edit_interactively(&mut self, console: &mut dyn Console) -> io::Result<()> {
275        let console_size = console.size_chars()?;
276
277        if self.content.is_empty() {
278            self.content.push(LineBuffer::default());
279        }
280
281        let mut refresh = RefreshKind::Full;
282        loop {
283            // The key handling below only deals with moving the insertion position within the file
284            // but does not bother to update the viewport. Adjust it now, if necessary.
285            let width = usize::from(console_size.x);
286            let height = usize::from(console_size.y);
287            if self.file_pos.line < self.viewport_pos.line {
288                self.viewport_pos.line = self.file_pos.line;
289                refresh.upgrade(RefreshKind::Full);
290            } else if self.file_pos.line > self.viewport_pos.line + height - 2 {
291                if self.file_pos.line > height - 2 {
292                    self.viewport_pos.line = self.file_pos.line - (height - 2);
293                } else {
294                    self.viewport_pos.line = 0;
295                }
296                refresh.upgrade(RefreshKind::Full);
297            }
298
299            if self.file_pos.col < self.viewport_pos.col {
300                self.viewport_pos.col = self.file_pos.col;
301                refresh.upgrade(RefreshKind::Full);
302            } else if self.file_pos.col >= self.viewport_pos.col + width {
303                self.viewport_pos.col = self.file_pos.col - width + 1;
304                refresh.upgrade(RefreshKind::Full);
305            }
306
307            // TODO(jmmv): We must handle the cursor visibility outside of the non-sync block
308            // because the current console implementation forces the cursor to be invisible
309            // when syncing is disabled.  This is suboptimal and should be fixed by decoupling
310            // the two properties...
311            console.hide_cursor()?;
312            match refresh {
313                RefreshKind::Full => self.refresh(console, console_size)?,
314                RefreshKind::CurrentLine => {
315                    self.refresh_status(console, console_size)?;
316                    self.refresh_current_line(console, console_size)?;
317                }
318                RefreshKind::None => {
319                    self.refresh_status(console, console_size)?;
320                    console.set_color(TEXT_COLOR.0, TEXT_COLOR.1)?;
321                }
322            }
323            refresh = RefreshKind::None;
324            let cursor_pos = {
325                let x = self.file_pos.col - self.viewport_pos.col;
326                let y = self.file_pos.line - self.viewport_pos.line;
327                if cfg!(debug_assertions) {
328                    CharsXY::new(
329                        u16::try_from(x).expect("Computed x must have fit on screen"),
330                        u16::try_from(y).expect("Computed y must have fit on screen"),
331                    )
332                } else {
333                    CharsXY::new(x as u16, y as u16)
334                }
335            };
336            console.locate(cursor_pos)?;
337            console.show_cursor()?;
338            console.sync_now()?;
339
340            match console.read_key().await? {
341                Key::Escape | Key::Interrupt => break,
342
343                Key::ArrowUp => self.move_up(1),
344
345                Key::ArrowDown => self.move_down(1),
346
347                Key::ArrowLeft => {
348                    if self.file_pos.col > 0 {
349                        self.file_pos.col -= 1;
350                        self.insert_col = self.file_pos.col;
351                    }
352                }
353
354                Key::ArrowRight => {
355                    if self.file_pos.col < self.content[self.file_pos.line].len() {
356                        self.file_pos.col += 1;
357                        self.insert_col = self.file_pos.col;
358                    }
359                }
360
361                Key::Backspace => {
362                    if self.file_pos.col > 0 {
363                        let line = &mut self.content[self.file_pos.line];
364
365                        let indent_pos = find_indent_end(line);
366                        let is_indent = indent_pos >= self.file_pos.col;
367                        let nremove = if is_indent {
368                            let new_pos = if self.file_pos.col >= INDENT_WIDTH {
369                                (self.file_pos.col - 1) / INDENT_WIDTH * INDENT_WIDTH
370                            } else {
371                                0
372                            };
373                            self.file_pos.col - new_pos
374                        } else {
375                            1
376                        };
377
378                        if self.file_pos.col == line.len() {
379                            if nremove > 0 {
380                                console.hide_cursor()?;
381                            }
382                            for _ in 0..nremove {
383                                console.clear(ClearType::PreviousChar)?;
384                            }
385                            if nremove > 0 {
386                                console.show_cursor()?;
387                            }
388                        } else {
389                            refresh.upgrade(RefreshKind::CurrentLine);
390                        }
391                        for _ in 0..nremove {
392                            line.remove(self.file_pos.col - 1);
393                            self.file_pos.col -= 1;
394                        }
395                        if nremove > 0 {
396                            self.dirty = true;
397                        }
398                    } else if self.file_pos.line > 0 {
399                        self.file_pos.col = self.content[self.file_pos.line - 1].len();
400                        self.join_next_line_into(self.file_pos.line - 1);
401                        self.file_pos.line -= 1;
402                        refresh.upgrade(RefreshKind::Full);
403                    }
404                    self.insert_col = self.file_pos.col;
405                }
406
407                Key::Delete | Key::EofOrDelete => {
408                    if self.file_pos.col < self.content[self.file_pos.line].len() {
409                        let line = &mut self.content[self.file_pos.line];
410                        line.remove(self.file_pos.col);
411                        self.dirty = true;
412                        if self.file_pos.col < line.len() {
413                            refresh.upgrade(RefreshKind::Full);
414                        } else {
415                            console.hide_cursor()?;
416                            console.clear(ClearType::UntilNewLine)?;
417                            console.show_cursor()?;
418                        }
419                    } else if self.file_pos.line + 1 < self.content.len() {
420                        self.join_next_line_into(self.file_pos.line);
421                        refresh.upgrade(RefreshKind::Full);
422                    }
423                }
424
425                Key::Char(ch) => {
426                    let mut buf = [0; 4];
427
428                    let line = &mut self.content[self.file_pos.line];
429                    if self.file_pos.col < line.len() {
430                        refresh.upgrade(RefreshKind::CurrentLine);
431                    }
432
433                    line.insert(self.file_pos.col, ch);
434                    self.file_pos.col += 1;
435                    self.insert_col = self.file_pos.col;
436
437                    if cursor_pos.x < console_size.x - 1 && refresh == RefreshKind::None {
438                        console.write(ch.encode_utf8(&mut buf))?;
439                    }
440
441                    self.dirty = true;
442                }
443
444                Key::End => {
445                    self.file_pos.col = self.content[self.file_pos.line].len();
446                    self.insert_col = self.file_pos.col;
447                }
448
449                Key::Home => {
450                    let indent_pos = find_indent_end(&self.content[self.file_pos.line]);
451                    if self.file_pos.col == indent_pos {
452                        self.file_pos.col = 0;
453                    } else {
454                        self.file_pos.col = indent_pos;
455                    }
456                    self.insert_col = self.file_pos.col;
457                }
458
459                Key::NewLine | Key::CarriageReturn => {
460                    let indent = copy_indent(&self.content[self.file_pos.line]);
461                    let indent_len = indent.len();
462
463                    let appending = (self.file_pos.line + 1 == self.content.len())
464                        && (self.file_pos.col == self.content[self.file_pos.line].len());
465
466                    let new = self.content[self.file_pos.line].split_off(self.file_pos.col);
467                    self.content.insert(
468                        self.file_pos.line + 1,
469                        LineBuffer::from(indent + &new.into_inner()),
470                    );
471                    if !appending {
472                        refresh.upgrade(RefreshKind::Full);
473                    }
474
475                    self.file_pos.col = indent_len;
476                    self.file_pos.line += 1;
477                    self.insert_col = self.file_pos.col;
478                    self.dirty = true;
479                }
480
481                Key::PageDown => self.move_down(usize::from(console_size.y - 2)),
482
483                Key::PageUp => self.move_up(usize::from(console_size.y - 2)),
484
485                Key::Tab => {
486                    let line = &mut self.content[self.file_pos.line];
487                    if self.file_pos.col < line.len() {
488                        refresh.upgrade(RefreshKind::CurrentLine);
489                    }
490
491                    let new_pos = (self.file_pos.col + INDENT_WIDTH) / INDENT_WIDTH * INDENT_WIDTH;
492                    let mut new_text = String::with_capacity(new_pos - self.file_pos.col);
493                    for _ in 0..new_text.capacity() {
494                        new_text.push(' ');
495                    }
496                    line.insert_str(self.file_pos.col, &new_text);
497                    self.file_pos.col = new_pos;
498                    self.insert_col = self.file_pos.col;
499                    if refresh == RefreshKind::None {
500                        console.write(&new_text)?;
501                    }
502                    self.dirty = true;
503                }
504
505                // TODO(jmmv): Should do something smarter with unknown keys.
506                Key::Unknown => (),
507            }
508        }
509
510        Ok(())
511    }
512}
513
514#[async_trait(?Send)]
515impl Program for Editor {
516    fn is_dirty(&self) -> bool {
517        self.dirty
518    }
519
520    async fn edit(&mut self, console: &mut dyn Console) -> io::Result<()> {
521        console.enter_alt()?;
522        let previous = console.set_sync(false)?;
523        let result = self.edit_interactively(console).await;
524        console.set_sync(previous)?;
525        console.leave_alt()?;
526        result
527    }
528
529    fn load(&mut self, name: Option<&str>, text: &str) {
530        self.name = name.map(str::to_owned);
531        self.content = text.lines().map(LineBuffer::from).collect();
532        self.dirty = false;
533        self.viewport_pos = FilePos::default();
534        self.file_pos = FilePos::default();
535        self.insert_col = 0;
536    }
537
538    fn name(&self) -> Option<&str> {
539        self.name.as_deref()
540    }
541
542    fn set_name(&mut self, name: &str) {
543        self.name = Some(name.to_owned());
544        self.dirty = false;
545    }
546
547    fn text(&self) -> String {
548        self.content
549            .iter()
550            .fold(String::new(), |contents, line| contents + &line.to_string() + "\n")
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use endbasic_std::testutils::*;
558    use futures_lite::future::block_on;
559
560    /// Name of the program to inject into the editor for testing.  The name is very short because
561    /// all tests operate on a pretty narrow window and the status bar would be mangled otherwise.
562    const TEST_FILENAME: &str = "X";
563
564    /// Syntactic sugar to easily instantiate a `CharsXY` at `(x,y)`.
565    ///
566    /// Note that the input arguments here are swapped.  This is partly because of historical
567    /// reasons, but also because virtually all editors (including this one) display file positions
568    /// with the line first followed by the column.  It's easier to reason about the tests below
569    /// when the order of the arguments to `linecol` matches `yx`.
570    fn yx(y: u16, x: u16) -> CharsXY {
571        CharsXY::new(x, y)
572    }
573
574    /// Syntactic sugar to easily instantiate a `FilePos` at `(line, col)`.
575    fn linecol(line: usize, col: usize) -> FilePos {
576        FilePos { line, col }
577    }
578
579    /// Builder pattern to construct the expected sequence of side-effects on the console.
580    #[must_use]
581    struct OutputBuilder {
582        console_size: CharsXY,
583        output: Vec<CapturedOut>,
584        dirty: bool,
585    }
586
587    impl OutputBuilder {
588        /// Constructs a new output builder with just the command to enter the alternate screen.
589        /// `console_size` holds the size of the mock console, which is used to determine where to
590        /// print the status bar.
591        fn new(console_size: CharsXY) -> Self {
592            Self {
593                console_size,
594                output: vec![CapturedOut::EnterAlt, CapturedOut::SetSync(false)],
595                dirty: false,
596            }
597        }
598
599        /// Records the console changes needed to update the status line to reflect a new `file_pos`
600        /// position.  Should not be used directly by tests.
601        ///
602        /// Note that, although `file_pos` is 0-indexed (to make it easier to reason about where
603        /// file changes actually happen in the internal buffers), we display the position as
604        /// 1-indexed here as the code under test does.
605        fn refresh_status(mut self, file_pos: FilePos) -> Self {
606            let row = file_pos.line + 1;
607            let column = file_pos.col + 1;
608
609            self.output.push(CapturedOut::Locate(yx(self.console_size.y - 1, 0)));
610            self.output.push(CapturedOut::SetColor(STATUS_COLOR.0, STATUS_COLOR.1));
611            if self.console_size.x < 30 {
612                // Arbitrary number to define "narrow console" in tests.
613                let details = &format!(" {}:{} ", row, column);
614                let mut status = String::new();
615                while status.len() + details.len() < usize::from(self.console_size.x) {
616                    status.push(' ');
617                }
618                status += details;
619                status.truncate(usize::from(self.console_size.x));
620                self.output.push(CapturedOut::Write(status));
621            } else {
622                let dirty_marker = if self.dirty { "*" } else { "" };
623                let details =
624                    &format!("| {}{} | Ln {}, Col {} ", TEST_FILENAME, dirty_marker, row, column);
625                let mut status = String::from(KEYS_SUMMARY);
626                while status.len() + details.len() < usize::from(self.console_size.x) {
627                    status.push(' ');
628                }
629                status += details;
630                self.output.push(CapturedOut::Write(status));
631            }
632            self
633        }
634
635        /// Records the console changes needed to incrementally update the editor, without going
636        /// through a full refresh, assuming a `file_pos` position.
637        fn quick_refresh(mut self, file_pos: FilePos, cursor: CharsXY) -> Self {
638            self.output.push(CapturedOut::HideCursor);
639            self = self.refresh_status(file_pos);
640            self.output.push(CapturedOut::SetColor(TEXT_COLOR.0, TEXT_COLOR.1));
641            self.output.push(CapturedOut::Locate(cursor));
642            self.output.push(CapturedOut::ShowCursor);
643            self.output.push(CapturedOut::SyncNow);
644            self
645        }
646
647        /// Records the console changes needed to refresh only the current line and the status bar.
648        fn refresh_line(mut self, file_pos: FilePos, line: &str, cursor: CharsXY) -> Self {
649            self.output.push(CapturedOut::HideCursor);
650            self = self.refresh_status(file_pos);
651            self.output.push(CapturedOut::SetColor(TEXT_COLOR.0, TEXT_COLOR.1));
652            self.output.push(CapturedOut::Locate(yx(cursor.y, 0)));
653            self.output.push(CapturedOut::Clear(ClearType::CurrentLine));
654            if !line.is_empty() {
655                self.output.push(CapturedOut::Write(line.to_string()));
656            }
657            self.output.push(CapturedOut::Locate(cursor));
658            self.output.push(CapturedOut::ShowCursor);
659            self.output.push(CapturedOut::SyncNow);
660            self
661        }
662
663        /// Records the console changes needed to refresh the whole console view.  The status line
664        /// is updated to reflect `file_pos`; the editor is pre-populated with the lines specified
665        /// in `previous`; and the `cursor` is placed at the given location.
666        fn refresh(mut self, file_pos: FilePos, previous: &[&str], cursor: CharsXY) -> Self {
667            self.output.push(CapturedOut::HideCursor);
668            self.output.push(CapturedOut::SetColor(TEXT_COLOR.0, TEXT_COLOR.1));
669            self.output.push(CapturedOut::Clear(ClearType::All));
670            self = self.refresh_status(file_pos);
671            self.output.push(CapturedOut::SetColor(TEXT_COLOR.0, TEXT_COLOR.1));
672            self.output.push(CapturedOut::Locate(yx(0, 0)));
673            for line in previous {
674                self.output.push(CapturedOut::Print(line.to_string()));
675            }
676            self.output.push(CapturedOut::Locate(cursor));
677            self.output.push(CapturedOut::ShowCursor);
678            self.output.push(CapturedOut::SyncNow);
679            self
680        }
681
682        /// Registers a new expected side-effect `co` on the console.
683        fn add(mut self, co: CapturedOut) -> Self {
684            self.output.push(co);
685            self
686        }
687
688        /// Registers that the file has been modified.
689        fn set_dirty(mut self) -> Self {
690            self.dirty = true;
691            self
692        }
693
694        /// Finalizes the list of expected side-effects on the console.
695        fn build(self) -> Vec<CapturedOut> {
696            let mut output = self.output;
697            output.push(CapturedOut::SetSync(true));
698            output.push(CapturedOut::LeaveAlt);
699            output
700        }
701    }
702
703    /// Runs the editor and expects that the resulting text matches `exp_text` and that the
704    /// side-effects on the console are those specified in `ob`.
705    ///
706    /// The editor can be pre-populated with some `previous` contents and the interactions with the
707    /// editor are specified in `cb`. Note that the final Esc key press needed to exit the editor
708    /// is automatically appended to `cb` here.
709    fn run_editor(previous: &str, exp_text: &str, mut console: MockConsole, ob: OutputBuilder) {
710        let mut editor = Editor::default();
711        editor.load(Some(TEST_FILENAME), previous);
712
713        console.add_input_keys(&[Key::Escape]);
714        block_on(editor.edit(&mut console)).unwrap();
715        assert_eq!(exp_text, editor.text());
716        assert_eq!(ob.dirty, editor.is_dirty());
717        assert_eq!(ob.build(), console.captured_out());
718    }
719
720    #[test]
721    fn test_program_behavior() {
722        let mut editor = Editor::default();
723        assert!(editor.text().is_empty());
724        assert!(!editor.is_dirty());
725
726        let mut console = MockConsole::default();
727        console.set_size_chars(yx(10, 40));
728        console.add_input_keys(&[Key::Escape]);
729        block_on(editor.edit(&mut console)).unwrap();
730        assert!(!editor.is_dirty());
731
732        console.add_input_keys(&[Key::Char('x'), Key::Escape]);
733        block_on(editor.edit(&mut console)).unwrap();
734        assert!(editor.is_dirty());
735
736        editor.load(Some(TEST_FILENAME), "some text\n    and more\n");
737        assert_eq!("some text\n    and more\n", editor.text());
738        assert!(!editor.is_dirty());
739
740        editor.load(Some(TEST_FILENAME), "different\n");
741        assert_eq!("different\n", editor.text());
742        assert!(!editor.is_dirty());
743
744        console.add_input_keys(&[Key::Char('x'), Key::Escape]);
745        block_on(editor.edit(&mut console)).unwrap();
746        assert!(editor.is_dirty());
747
748        editor.set_name("SAVED");
749        assert!(!editor.is_dirty());
750    }
751
752    #[test]
753    fn test_force_trailing_newline() {
754        let mut editor = Editor::default();
755        assert!(editor.text().is_empty());
756
757        editor.load(Some(TEST_FILENAME), "missing\nnewline at eof");
758        assert_eq!("missing\nnewline at eof\n", editor.text());
759    }
760
761    #[test]
762    fn test_editing_with_previous_content_starts_on_top_left() {
763        let mut cb = MockConsole::default();
764        cb.set_size_chars(yx(10, 40));
765        let mut ob = OutputBuilder::new(yx(10, 40));
766        ob = ob.refresh(linecol(0, 0), &["previous content"], yx(0, 0));
767
768        run_editor("previous content", "previous content\n", cb, ob);
769    }
770
771    #[test]
772    fn test_insert_in_empty_file() {
773        let mut cb = MockConsole::default();
774        cb.set_size_chars(yx(10, 40));
775        let mut ob = OutputBuilder::new(yx(10, 40));
776        ob = ob.refresh(linecol(0, 0), &[""], yx(0, 0));
777
778        cb.add_input_chars("abcéà");
779        ob = ob.set_dirty();
780        ob = ob.add(CapturedOut::Write("a".to_string()));
781        ob = ob.quick_refresh(linecol(0, 1), yx(0, 1));
782        ob = ob.add(CapturedOut::Write("b".to_string()));
783        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
784        ob = ob.add(CapturedOut::Write("c".to_string()));
785        ob = ob.quick_refresh(linecol(0, 3), yx(0, 3));
786        ob = ob.add(CapturedOut::Write("é".to_string()));
787        ob = ob.quick_refresh(linecol(0, 4), yx(0, 4));
788        ob = ob.add(CapturedOut::Write("à".to_string()));
789        ob = ob.quick_refresh(linecol(0, 5), yx(0, 5));
790
791        cb.add_input_keys(&[Key::NewLine]);
792        ob = ob.quick_refresh(linecol(1, 0), yx(1, 0));
793
794        cb.add_input_keys(&[Key::CarriageReturn]);
795        ob = ob.quick_refresh(linecol(2, 0), yx(2, 0));
796
797        cb.add_input_chars("2");
798        ob = ob.add(CapturedOut::Write("2".to_string()));
799        ob = ob.quick_refresh(linecol(2, 1), yx(2, 1));
800
801        run_editor("", "abcéà\n\n2\n", cb, ob);
802    }
803
804    #[test]
805    fn test_insert_before_previous_content() {
806        let mut cb = MockConsole::default();
807        cb.set_size_chars(yx(10, 40));
808        let mut ob = OutputBuilder::new(yx(10, 40));
809        ob = ob.refresh(linecol(0, 0), &["previous content"], yx(0, 0));
810
811        cb.add_input_chars("a");
812        ob = ob.set_dirty();
813        ob = ob.refresh_line(linecol(0, 1), "aprevious content", yx(0, 1));
814
815        cb.add_input_chars("b");
816        ob = ob.refresh_line(linecol(0, 2), "abprevious content", yx(0, 2));
817
818        cb.add_input_chars("c");
819        ob = ob.refresh_line(linecol(0, 3), "abcprevious content", yx(0, 3));
820
821        cb.add_input_chars(" ");
822        ob = ob.refresh_line(linecol(0, 4), "abc previous content", yx(0, 4));
823
824        run_editor("previous content", "abc previous content\n", cb, ob);
825    }
826
827    #[test]
828    fn test_insert_before_last_character() {
829        let mut cb = MockConsole::default();
830        cb.set_size_chars(yx(10, 40));
831        let mut ob = OutputBuilder::new(yx(10, 40));
832        ob = ob.refresh(linecol(0, 0), &[""], yx(0, 0));
833
834        cb.add_input_chars("abc");
835        ob = ob.set_dirty();
836        ob = ob.add(CapturedOut::Write("a".to_string()));
837        ob = ob.quick_refresh(linecol(0, 1), yx(0, 1));
838        ob = ob.add(CapturedOut::Write("b".to_string()));
839        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
840        ob = ob.add(CapturedOut::Write("c".to_string()));
841        ob = ob.quick_refresh(linecol(0, 3), yx(0, 3));
842
843        cb.add_input_keys(&[Key::ArrowLeft]);
844        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
845
846        cb.add_input_chars("d");
847        ob = ob.refresh_line(linecol(0, 3), "abdc", yx(0, 3));
848
849        run_editor("", "abdc\n", cb, ob);
850    }
851
852    #[test]
853    fn test_insert_newline_in_middle() {
854        let mut cb = MockConsole::default();
855        cb.set_size_chars(yx(10, 40));
856        let mut ob = OutputBuilder::new(yx(10, 40));
857        ob = ob.refresh(linecol(0, 0), &[""], yx(0, 0));
858
859        cb.add_input_chars("abc");
860        ob = ob.set_dirty();
861        ob = ob.add(CapturedOut::Write("a".to_string()));
862        ob = ob.quick_refresh(linecol(0, 1), yx(0, 1));
863        ob = ob.add(CapturedOut::Write("b".to_string()));
864        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
865        ob = ob.add(CapturedOut::Write("c".to_string()));
866        ob = ob.quick_refresh(linecol(0, 3), yx(0, 3));
867
868        cb.add_input_keys(&[Key::ArrowLeft]);
869        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
870
871        cb.add_input_keys(&[Key::NewLine]);
872        ob = ob.refresh(linecol(1, 0), &["ab", "c"], yx(1, 0));
873
874        cb.add_input_keys(&[Key::ArrowUp]);
875        ob = ob.quick_refresh(linecol(0, 0), yx(0, 0));
876        cb.add_input_keys(&[Key::ArrowRight]);
877        ob = ob.quick_refresh(linecol(0, 1), yx(0, 1));
878        cb.add_input_keys(&[Key::ArrowRight]);
879        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
880
881        cb.add_input_keys(&[Key::NewLine]);
882        ob = ob.refresh(linecol(1, 0), &["ab", "", "c"], yx(1, 0));
883
884        run_editor("", "ab\n\nc\n", cb, ob);
885    }
886
887    #[test]
888    fn test_split_last_line() {
889        let mut cb = MockConsole::default();
890        cb.set_size_chars(yx(10, 40));
891        let mut ob = OutputBuilder::new(yx(10, 40));
892        ob = ob.refresh(linecol(0, 0), &[""], yx(0, 0));
893
894        cb.add_input_chars("  abcd");
895        ob = ob.set_dirty();
896        ob = ob.add(CapturedOut::Write(" ".to_string()));
897        ob = ob.quick_refresh(linecol(0, 1), yx(0, 1));
898        ob = ob.add(CapturedOut::Write(" ".to_string()));
899        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
900        ob = ob.add(CapturedOut::Write("a".to_string()));
901        ob = ob.quick_refresh(linecol(0, 3), yx(0, 3));
902        ob = ob.add(CapturedOut::Write("b".to_string()));
903        ob = ob.quick_refresh(linecol(0, 4), yx(0, 4));
904        ob = ob.add(CapturedOut::Write("c".to_string()));
905        ob = ob.quick_refresh(linecol(0, 5), yx(0, 5));
906        ob = ob.add(CapturedOut::Write("d".to_string()));
907        ob = ob.quick_refresh(linecol(0, 6), yx(0, 6));
908
909        cb.add_input_keys(&[Key::ArrowLeft]);
910        ob = ob.quick_refresh(linecol(0, 5), yx(0, 5));
911        cb.add_input_keys(&[Key::ArrowLeft]);
912        ob = ob.quick_refresh(linecol(0, 4), yx(0, 4));
913
914        cb.add_input_keys(&[Key::NewLine]);
915        ob = ob.refresh(linecol(1, 2), &["  ab", "  cd"], yx(1, 2));
916
917        run_editor("", "  ab\n  cd\n", cb, ob);
918    }
919
920    #[test]
921    fn test_move_in_empty_file() {
922        let mut cb = MockConsole::default();
923        cb.set_size_chars(yx(10, 40));
924        let mut ob = OutputBuilder::new(yx(10, 40));
925        ob = ob.refresh(linecol(0, 0), &[""], yx(0, 0));
926
927        for k in &[
928            Key::ArrowUp,
929            Key::ArrowDown,
930            Key::ArrowLeft,
931            Key::ArrowRight,
932            Key::PageUp,
933            Key::PageDown,
934        ] {
935            cb.add_input_keys(&[*k]);
936            ob = ob.quick_refresh(linecol(0, 0), yx(0, 0));
937        }
938
939        run_editor("", "\n", cb, ob);
940    }
941
942    #[test]
943    fn test_move_end() {
944        let mut cb = MockConsole::default();
945        cb.set_size_chars(yx(10, 40));
946        let mut ob = OutputBuilder::new(yx(10, 40));
947        ob = ob.refresh(linecol(0, 0), &["text"], yx(0, 0));
948
949        cb.add_input_keys(&[Key::End]);
950        ob = ob.quick_refresh(linecol(0, 4), yx(0, 4));
951
952        cb.add_input_chars(".");
953        ob = ob.set_dirty();
954        ob = ob.add(CapturedOut::Write(".".to_string()));
955        ob = ob.quick_refresh(linecol(0, 5), yx(0, 5));
956
957        run_editor("text", "text.\n", cb, ob);
958    }
959
960    #[test]
961    fn test_move_home_no_indent() {
962        let mut cb = MockConsole::default();
963        cb.set_size_chars(yx(10, 40));
964        let mut ob = OutputBuilder::new(yx(10, 40));
965        ob = ob.refresh(linecol(0, 0), &["text"], yx(0, 0));
966
967        cb.add_input_keys(&[Key::ArrowRight]);
968        ob = ob.quick_refresh(linecol(0, 1), yx(0, 1));
969
970        cb.add_input_keys(&[Key::ArrowRight]);
971        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
972
973        cb.add_input_keys(&[Key::Home]);
974        ob = ob.quick_refresh(linecol(0, 0), yx(0, 0));
975
976        cb.add_input_chars(".");
977        ob = ob.set_dirty();
978        ob = ob.refresh_line(linecol(0, 1), ".text", yx(0, 1));
979
980        cb.add_input_keys(&[Key::Home]);
981        ob = ob.quick_refresh(linecol(0, 0), yx(0, 0));
982
983        cb.add_input_chars(",");
984        ob = ob.refresh_line(linecol(0, 1), ",.text", yx(0, 1));
985
986        run_editor("text", ",.text\n", cb, ob);
987    }
988
989    #[test]
990    fn test_move_home_with_indent() {
991        let mut cb = MockConsole::default();
992        cb.set_size_chars(yx(10, 40));
993        let mut ob = OutputBuilder::new(yx(10, 40));
994        ob = ob.refresh(linecol(0, 0), &["  text"], yx(0, 0));
995
996        cb.add_input_keys(&[Key::Home]);
997        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
998
999        cb.add_input_keys(&[Key::Home]);
1000        ob = ob.quick_refresh(linecol(0, 0), yx(0, 0));
1001
1002        cb.add_input_keys(&[Key::ArrowRight]);
1003        ob = ob.quick_refresh(linecol(0, 1), yx(0, 1));
1004
1005        cb.add_input_keys(&[Key::Home]);
1006        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
1007
1008        cb.add_input_keys(&[Key::ArrowRight]);
1009        ob = ob.quick_refresh(linecol(0, 3), yx(0, 3));
1010
1011        cb.add_input_keys(&[Key::Home]);
1012        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
1013
1014        cb.add_input_chars(".");
1015        ob = ob.set_dirty();
1016        ob = ob.refresh_line(linecol(0, 3), "  .text", yx(0, 3));
1017
1018        run_editor("  text", "  .text\n", cb, ob);
1019    }
1020
1021    #[test]
1022    fn test_move_page_down_up() {
1023        let mut cb = MockConsole::default();
1024        cb.set_size_chars(yx(10, 40));
1025        let mut ob = OutputBuilder::new(yx(10, 40));
1026        ob = ob.refresh(linecol(0, 0), &["1", "2", "3", "4", "5", "6", "7", "8", "9"], yx(0, 0));
1027
1028        cb.add_input_keys(&[Key::PageDown]);
1029        ob = ob.quick_refresh(linecol(8, 0), yx(8, 0));
1030
1031        cb.add_input_keys(&[Key::PageDown]);
1032        ob = ob.refresh(
1033            linecol(16, 0),
1034            &["9", "10", "11", "12", "13", "14", "15", "16", "17"],
1035            yx(8, 0),
1036        );
1037
1038        cb.add_input_keys(&[Key::PageDown]);
1039        ob = ob.refresh(
1040            linecol(19, 0),
1041            &["12", "13", "14", "15", "16", "17", "18", "19", "20"],
1042            yx(8, 0),
1043        );
1044
1045        cb.add_input_keys(&[Key::PageDown]);
1046        ob = ob.quick_refresh(linecol(19, 0), yx(8, 0));
1047
1048        cb.add_input_keys(&[Key::PageUp]);
1049        ob = ob.quick_refresh(linecol(11, 0), yx(0, 0));
1050
1051        cb.add_input_keys(&[Key::PageUp]);
1052        ob = ob.refresh(linecol(3, 0), &["4", "5", "6", "7", "8", "9", "10", "11", "12"], yx(0, 0));
1053
1054        cb.add_input_keys(&[Key::PageUp]);
1055        ob = ob.refresh(linecol(0, 0), &["1", "2", "3", "4", "5", "6", "7", "8", "9"], yx(0, 0));
1056
1057        cb.add_input_keys(&[Key::PageUp]);
1058        ob = ob.quick_refresh(linecol(0, 0), yx(0, 0));
1059
1060        run_editor(
1061            "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n",
1062            "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n",
1063            cb,
1064            ob,
1065        );
1066    }
1067
1068    #[test]
1069    fn test_tab_append() {
1070        let mut cb = MockConsole::default();
1071        cb.set_size_chars(yx(10, 40));
1072        let mut ob = OutputBuilder::new(yx(10, 40));
1073        ob = ob.refresh(linecol(0, 0), &[""], yx(0, 0));
1074
1075        cb.add_input_keys(&[Key::Tab]);
1076        ob = ob.set_dirty();
1077        ob = ob.add(CapturedOut::Write("    ".to_string()));
1078        ob = ob.quick_refresh(linecol(0, 4), yx(0, 4));
1079
1080        cb.add_input_chars("x");
1081        ob = ob.add(CapturedOut::Write("x".to_string()));
1082        ob = ob.quick_refresh(linecol(0, 5), yx(0, 5));
1083
1084        cb.add_input_keys(&[Key::Tab]);
1085        ob = ob.add(CapturedOut::Write("   ".to_string()));
1086        ob = ob.quick_refresh(linecol(0, 8), yx(0, 8));
1087
1088        run_editor("", "    x   \n", cb, ob);
1089    }
1090
1091    #[test]
1092    fn test_tab_existing_content() {
1093        let mut cb = MockConsole::default();
1094        cb.set_size_chars(yx(10, 40));
1095        let mut ob = OutputBuilder::new(yx(10, 40));
1096        ob = ob.refresh(linecol(0, 0), &["."], yx(0, 0));
1097
1098        cb.add_input_keys(&[Key::Tab]);
1099        ob = ob.set_dirty();
1100        ob = ob.refresh_line(linecol(0, 4), "    .", yx(0, 4));
1101
1102        cb.add_input_keys(&[Key::Tab]);
1103        ob = ob.refresh_line(linecol(0, 8), "        .", yx(0, 8));
1104
1105        run_editor(".", "        .\n", cb, ob);
1106    }
1107
1108    #[test]
1109    fn test_tab_remove_empty_line() {
1110        let mut cb = MockConsole::default();
1111        cb.set_size_chars(yx(10, 40));
1112        let mut ob = OutputBuilder::new(yx(10, 40));
1113        ob = ob.refresh(linecol(0, 0), &["          "], yx(0, 0));
1114
1115        cb.add_input_keys(&[Key::End]);
1116        ob = ob.quick_refresh(linecol(0, 10), yx(0, 10));
1117
1118        cb.add_input_keys(&[Key::Backspace]);
1119        ob = ob.set_dirty();
1120        ob = ob.add(CapturedOut::HideCursor);
1121        ob = ob.add(CapturedOut::Clear(ClearType::PreviousChar));
1122        ob = ob.add(CapturedOut::Clear(ClearType::PreviousChar));
1123        ob = ob.add(CapturedOut::ShowCursor);
1124        ob = ob.quick_refresh(linecol(0, 8), yx(0, 8));
1125
1126        cb.add_input_keys(&[Key::Backspace]);
1127        ob = ob.add(CapturedOut::HideCursor);
1128        ob = ob.add(CapturedOut::Clear(ClearType::PreviousChar));
1129        ob = ob.add(CapturedOut::Clear(ClearType::PreviousChar));
1130        ob = ob.add(CapturedOut::Clear(ClearType::PreviousChar));
1131        ob = ob.add(CapturedOut::Clear(ClearType::PreviousChar));
1132        ob = ob.add(CapturedOut::ShowCursor);
1133        ob = ob.quick_refresh(linecol(0, 4), yx(0, 4));
1134
1135        cb.add_input_keys(&[Key::Backspace]);
1136        ob = ob.add(CapturedOut::HideCursor);
1137        ob = ob.add(CapturedOut::Clear(ClearType::PreviousChar));
1138        ob = ob.add(CapturedOut::Clear(ClearType::PreviousChar));
1139        ob = ob.add(CapturedOut::Clear(ClearType::PreviousChar));
1140        ob = ob.add(CapturedOut::Clear(ClearType::PreviousChar));
1141        ob = ob.add(CapturedOut::ShowCursor);
1142        ob = ob.quick_refresh(linecol(0, 0), yx(0, 0));
1143
1144        cb.add_input_keys(&[Key::Backspace]);
1145        ob = ob.quick_refresh(linecol(0, 0), yx(0, 0));
1146
1147        run_editor("          ", "\n", cb, ob);
1148    }
1149
1150    #[test]
1151    fn test_tab_remove_before_some_text() {
1152        let mut cb = MockConsole::default();
1153        cb.set_size_chars(yx(10, 40));
1154        let mut ob = OutputBuilder::new(yx(10, 40));
1155        ob = ob.refresh(linecol(0, 0), &["          aligned"], yx(0, 0));
1156
1157        for i in 0..10 {
1158            cb.add_input_keys(&[Key::ArrowRight]);
1159            ob = ob.quick_refresh(linecol(0, i + 1), yx(0, u16::try_from(i + 1).unwrap()));
1160        }
1161
1162        cb.add_input_keys(&[Key::Backspace]);
1163        ob = ob.set_dirty();
1164        ob = ob.refresh_line(linecol(0, 8), "        aligned", yx(0, 8));
1165
1166        cb.add_input_keys(&[Key::Backspace]);
1167        ob = ob.refresh_line(linecol(0, 4), "    aligned", yx(0, 4));
1168
1169        cb.add_input_keys(&[Key::Backspace]);
1170        ob = ob.refresh_line(linecol(0, 0), "aligned", yx(0, 0));
1171
1172        cb.add_input_keys(&[Key::Backspace]);
1173        ob = ob.quick_refresh(linecol(0, 0), yx(0, 0));
1174
1175        run_editor("          aligned", "aligned\n", cb, ob);
1176    }
1177
1178    fn do_delete_in_middle_of_line_test(key: Key) {
1179        let mut cb = MockConsole::default();
1180        cb.set_size_chars(yx(10, 40));
1181        let mut ob = OutputBuilder::new(yx(10, 40));
1182        ob = ob.refresh(linecol(0, 0), &["has a typo"], yx(0, 0));
1183
1184        for i in 0..7u16 {
1185            cb.add_input_keys(&[Key::ArrowRight]);
1186            ob = ob.quick_refresh(linecol(0, usize::from(i + 1)), yx(0, i + 1));
1187        }
1188
1189        cb.add_input_keys(&[key]);
1190        ob = ob.set_dirty();
1191        ob = ob.refresh(linecol(0, 7), &["has a tpo"], yx(0, 7));
1192
1193        run_editor("has a typo\n", "has a tpo\n", cb, ob);
1194    }
1195
1196    #[test]
1197    fn test_delete_in_middle_of_line() {
1198        do_delete_in_middle_of_line_test(Key::Delete)
1199    }
1200
1201    #[test]
1202    fn test_ctrl_d_in_middle_of_line() {
1203        do_delete_in_middle_of_line_test(Key::EofOrDelete)
1204    }
1205
1206    #[test]
1207    fn test_delete_last_character_of_line() {
1208        let mut cb = MockConsole::default();
1209        cb.set_size_chars(yx(10, 40));
1210        let mut ob = OutputBuilder::new(yx(10, 40));
1211        ob = ob.refresh(linecol(0, 0), &["sample"], yx(0, 0));
1212
1213        for i in 0..5u16 {
1214            cb.add_input_keys(&[Key::ArrowRight]);
1215            ob = ob.quick_refresh(linecol(0, usize::from(i + 1)), yx(0, i + 1));
1216        }
1217
1218        cb.add_input_keys(&[Key::Delete]);
1219        ob = ob.set_dirty();
1220        ob = ob.add(CapturedOut::HideCursor);
1221        ob = ob.add(CapturedOut::Clear(ClearType::UntilNewLine));
1222        ob = ob.add(CapturedOut::ShowCursor);
1223        ob = ob.quick_refresh(linecol(0, 5), yx(0, 5));
1224
1225        run_editor("sample\n", "sampl\n", cb, ob);
1226    }
1227
1228    fn do_delete_joins_with_next_line_test(key: Key) {
1229        let mut cb = MockConsole::default();
1230        cb.set_size_chars(yx(10, 40));
1231        let mut ob = OutputBuilder::new(yx(10, 40));
1232        ob = ob.refresh(linecol(0, 0), &["first", "second"], yx(0, 0));
1233
1234        for i in 0..5u16 {
1235            cb.add_input_keys(&[Key::ArrowRight]);
1236            ob = ob.quick_refresh(linecol(0, usize::from(i + 1)), yx(0, i + 1));
1237        }
1238
1239        cb.add_input_keys(&[key]);
1240        ob = ob.set_dirty();
1241        ob = ob.refresh(linecol(0, 5), &["firstsecond"], yx(0, 5));
1242
1243        run_editor("first\nsecond\n", "firstsecond\n", cb, ob);
1244    }
1245
1246    #[test]
1247    fn test_delete_joins_with_next_line() {
1248        do_delete_joins_with_next_line_test(Key::Delete)
1249    }
1250
1251    #[test]
1252    fn test_ctrl_d_joins_with_next_line() {
1253        do_delete_joins_with_next_line_test(Key::EofOrDelete)
1254    }
1255
1256    #[test]
1257    fn test_delete_at_end_of_file_is_ignored() {
1258        let mut cb = MockConsole::default();
1259        cb.set_size_chars(yx(10, 40));
1260        let mut ob = OutputBuilder::new(yx(10, 40));
1261        ob = ob.refresh(linecol(0, 0), &["sample"], yx(0, 0));
1262
1263        for i in 0..6u16 {
1264            cb.add_input_keys(&[Key::ArrowRight]);
1265            ob = ob.quick_refresh(linecol(0, usize::from(i + 1)), yx(0, i + 1));
1266        }
1267
1268        cb.add_input_keys(&[Key::Delete]);
1269        ob = ob.quick_refresh(linecol(0, 6), yx(0, 6));
1270
1271        run_editor("sample\n", "sample\n", cb, ob);
1272    }
1273
1274    #[test]
1275    fn test_move_preserves_insertion_column() {
1276        let mut cb = MockConsole::default();
1277        cb.set_size_chars(yx(10, 40));
1278        let mut ob = OutputBuilder::new(yx(10, 40));
1279        ob = ob.refresh(linecol(0, 0), &["longer", "a", "longer", "b"], yx(0, 0));
1280
1281        cb.add_input_keys(&[Key::ArrowRight]);
1282        ob = ob.quick_refresh(linecol(0, 1), yx(0, 1));
1283
1284        cb.add_input_keys(&[Key::ArrowRight]);
1285        ob = ob.quick_refresh(linecol(0, 2), yx(0, 2));
1286
1287        cb.add_input_keys(&[Key::ArrowRight]);
1288        ob = ob.quick_refresh(linecol(0, 3), yx(0, 3));
1289
1290        cb.add_input_keys(&[Key::ArrowRight]);
1291        ob = ob.quick_refresh(linecol(0, 4), yx(0, 4));
1292
1293        cb.add_input_keys(&[Key::ArrowDown]);
1294        ob = ob.quick_refresh(linecol(1, 1), yx(1, 1));
1295
1296        cb.add_input_keys(&[Key::ArrowDown]);
1297        ob = ob.quick_refresh(linecol(2, 4), yx(2, 4));
1298
1299        cb.add_input_keys(&[Key::Char('X')]);
1300        ob = ob.set_dirty();
1301        ob = ob.refresh_line(linecol(2, 5), "longXer", yx(2, 5));
1302
1303        cb.add_input_keys(&[Key::ArrowDown]);
1304        ob = ob.quick_refresh(linecol(3, 1), yx(3, 1));
1305
1306        cb.add_input_keys(&[Key::Char('Z')]);
1307        ob = ob.add(CapturedOut::Write("Z".to_string()));
1308        ob = ob.quick_refresh(linecol(3, 2), yx(3, 2));
1309
1310        run_editor("longer\na\nlonger\nb\n", "longer\na\nlongXer\nbZ\n", cb, ob);
1311    }
1312
1313    #[test]
1314    fn test_move_down_preserves_insertion_column_with_horizontal_scrolling() {
1315        let mut cb = MockConsole::default();
1316        cb.set_size_chars(yx(10, 40));
1317        let mut ob = OutputBuilder::new(yx(10, 40));
1318        ob = ob.refresh(
1319            linecol(0, 0),
1320            &[
1321                "this is a line of text with more than 40",
1322                "short",
1323                "a",
1324                "",
1325                "another line of text with more than 40 c",
1326            ],
1327            yx(0, 0),
1328        );
1329
1330        // Move the cursor to the right boundary.
1331        for col in 0u16..39u16 {
1332            cb.add_input_keys(&[Key::ArrowRight]);
1333            ob = ob.quick_refresh(linecol(0, usize::from(col) + 1), yx(0, col + 1));
1334        }
1335
1336        // Push the insertion point over the right boundary to cause scrolling.
1337        cb.add_input_keys(&[Key::ArrowRight]);
1338        ob = ob.refresh(
1339            linecol(0, 40),
1340            &[
1341                "his is a line of text with more than 40 ",
1342                "hort",
1343                "",
1344                "",
1345                "nother line of text with more than 40 ch",
1346            ],
1347            yx(0, 39),
1348        );
1349        cb.add_input_keys(&[Key::ArrowRight]);
1350        ob = ob.refresh(
1351            linecol(0, 41),
1352            &[
1353                "is is a line of text with more than 40 c",
1354                "ort",
1355                "",
1356                "",
1357                "other line of text with more than 40 cha",
1358            ],
1359            yx(0, 39),
1360        );
1361
1362        // Move down to a shorter line whose end character is still visible. No scrolling.
1363        cb.add_input_keys(&[Key::ArrowDown]);
1364        ob = ob.quick_refresh(linecol(1, 5), yx(1, 3));
1365
1366        // Move down to a shorter line that's not visible but for which insertion can still happen
1367        // without scrolling.
1368        cb.add_input_keys(&[Key::ArrowDown]);
1369        ob = ob.refresh(
1370            linecol(2, 1),
1371            &[
1372                "his is a line of text with more than 40 ",
1373                "hort",
1374                "",
1375                "",
1376                "nother line of text with more than 40 ch",
1377            ],
1378            yx(2, 0),
1379        );
1380
1381        // Move down to an empty line that requires horizontal scrolling for proper insertion.
1382        cb.add_input_keys(&[Key::ArrowDown]);
1383        ob = ob.refresh(
1384            linecol(3, 0),
1385            &[
1386                "this is a line of text with more than 40",
1387                "short",
1388                "a",
1389                "",
1390                "another line of text with more than 40 c",
1391            ],
1392            yx(3, 0),
1393        );
1394
1395        // Move down to the last line, which is long again and thus needs scrolling to the right to
1396        // make the insertion point visible.
1397        cb.add_input_keys(&[Key::ArrowDown]);
1398        ob = ob.refresh(
1399            linecol(4, 41),
1400            &[
1401                "is is a line of text with more than 40 c",
1402                "ort",
1403                "",
1404                "",
1405                "other line of text with more than 40 cha",
1406            ],
1407            yx(4, 39),
1408        );
1409
1410        run_editor(
1411            "this is a line of text with more than 40 characters\nshort\na\n\nanother line of text with more than 40 characters\n",
1412            "this is a line of text with more than 40 characters\nshort\na\n\nanother line of text with more than 40 characters\n",
1413            cb,
1414            ob,
1415        );
1416    }
1417
1418    #[test]
1419    fn test_move_up_preserves_insertion_column_with_horizontal_scrolling() {
1420        let mut cb = MockConsole::default();
1421        cb.set_size_chars(yx(10, 40));
1422        let mut ob = OutputBuilder::new(yx(10, 40));
1423        ob = ob.refresh(
1424            linecol(0, 0),
1425            &[
1426                "this is a line of text with more than 40",
1427                "",
1428                "a",
1429                "short",
1430                "another line of text with more than 40 c",
1431            ],
1432            yx(0, 0),
1433        );
1434
1435        // Move to the last line.
1436        for i in 0u16..4u16 {
1437            cb.add_input_keys(&[Key::ArrowDown]);
1438            ob = ob.quick_refresh(linecol(usize::from(i + 1), 0), yx(i + 1, 0));
1439        }
1440
1441        // Move the cursor to the right boundary.
1442        for col in 0u16..39u16 {
1443            cb.add_input_keys(&[Key::ArrowRight]);
1444            ob = ob.quick_refresh(linecol(4, usize::from(col + 1)), yx(4, col + 1));
1445        }
1446
1447        // Push the insertion point over the right boundary to cause scrolling.
1448        cb.add_input_keys(&[Key::ArrowRight]);
1449        ob = ob.refresh(
1450            linecol(4, 40),
1451            &[
1452                "his is a line of text with more than 40 ",
1453                "",
1454                "",
1455                "hort",
1456                "nother line of text with more than 40 ch",
1457            ],
1458            yx(4, 39),
1459        );
1460        cb.add_input_keys(&[Key::ArrowRight]);
1461        ob = ob.refresh(
1462            linecol(4, 41),
1463            &[
1464                "is is a line of text with more than 40 c",
1465                "",
1466                "",
1467                "ort",
1468                "other line of text with more than 40 cha",
1469            ],
1470            yx(4, 39),
1471        );
1472
1473        // Move up to a shorter line whose end character is still visible. No scrolling.
1474        cb.add_input_keys(&[Key::ArrowUp]);
1475        ob = ob.quick_refresh(linecol(3, 5), yx(3, 3));
1476
1477        // Move up to a shorter line that's not visible but for which insertion can still happen
1478        // without scrolling.
1479        cb.add_input_keys(&[Key::ArrowUp]);
1480        ob = ob.refresh(
1481            linecol(2, 1),
1482            &[
1483                "his is a line of text with more than 40 ",
1484                "",
1485                "",
1486                "hort",
1487                "nother line of text with more than 40 ch",
1488            ],
1489            yx(2, 0),
1490        );
1491
1492        // Move up to an empty line that requires horizontal scrolling for proper insertion.
1493        cb.add_input_keys(&[Key::ArrowUp]);
1494        ob = ob.refresh(
1495            linecol(1, 0),
1496            &[
1497                "this is a line of text with more than 40",
1498                "",
1499                "a",
1500                "short",
1501                "another line of text with more than 40 c",
1502            ],
1503            yx(1, 0),
1504        );
1505
1506        // Move up to the first line, which is long again and thus needs scrolling to the right to
1507        // make the insertion point visible.
1508        cb.add_input_keys(&[Key::ArrowUp]);
1509        ob = ob.refresh(
1510            linecol(0, 41),
1511            &[
1512                "is is a line of text with more than 40 c",
1513                "",
1514                "",
1515                "ort",
1516                "other line of text with more than 40 cha",
1517            ],
1518            yx(0, 39),
1519        );
1520
1521        run_editor(
1522            "this is a line of text with more than 40 characters\n\na\nshort\nanother line of text with more than 40 characters\n",
1523            "this is a line of text with more than 40 characters\n\na\nshort\nanother line of text with more than 40 characters\n",
1524            cb,
1525            ob,
1526        );
1527    }
1528
1529    #[test]
1530    fn test_horizontal_scrolling() {
1531        let mut cb = MockConsole::default();
1532        cb.set_size_chars(yx(10, 40));
1533        let mut ob = OutputBuilder::new(yx(10, 40));
1534        ob = ob.refresh(linecol(0, 0), &["ab", "", "xyz"], yx(0, 0));
1535
1536        cb.add_input_keys(&[Key::ArrowDown]);
1537        ob = ob.quick_refresh(linecol(1, 0), yx(1, 0));
1538
1539        // Insert characters until the screen's right boundary.
1540        for (col, ch) in "123456789012345678901234567890123456789".chars().enumerate() {
1541            cb.add_input_keys(&[Key::Char(ch)]);
1542            ob = ob.set_dirty();
1543            let mut buf = [0u8; 4];
1544            ob = ob.add(CapturedOut::Write(ch.encode_utf8(&mut buf).to_string()));
1545            ob = ob.quick_refresh(linecol(1, col + 1), yx(1, u16::try_from(col + 1).unwrap()));
1546        }
1547
1548        // Push the insertion line over the right boundary and test that surrounding lines scroll as
1549        // well.
1550        cb.add_input_keys(&[Key::Char('A')]);
1551        ob = ob.refresh(
1552            linecol(1, 40),
1553            &["b", "23456789012345678901234567890123456789A", "yz"],
1554            yx(1, 39),
1555        );
1556        cb.add_input_keys(&[Key::Char('B')]);
1557        ob = ob.refresh(
1558            linecol(1, 41),
1559            &["", "3456789012345678901234567890123456789AB", "z"],
1560            yx(1, 39),
1561        );
1562        cb.add_input_keys(&[Key::Char('C')]);
1563        ob = ob.refresh(
1564            linecol(1, 42),
1565            &["", "456789012345678901234567890123456789ABC", ""],
1566            yx(1, 39),
1567        );
1568
1569        // Move back a few characters, without pushing over the left boundary, and then insert two
1570        // characters: one will cause the insertion line to fill up the empty space left by the
1571        // cursor and the other will cause the view of the insertion line to be truncated on the
1572        // right side.
1573        for (file_col, cursor_col) in &[(41, 38), (40, 37), (39, 36)] {
1574            cb.add_input_keys(&[Key::ArrowLeft]);
1575            ob = ob.quick_refresh(linecol(1, *file_col), yx(1, *cursor_col));
1576        }
1577        cb.add_input_keys(&[Key::Char('D')]);
1578        ob = ob.refresh_line(linecol(1, 40), "456789012345678901234567890123456789DABC", yx(1, 37));
1579        cb.add_input_keys(&[Key::Char('E')]);
1580        ob = ob.refresh_line(linecol(1, 41), "456789012345678901234567890123456789DEAB", yx(1, 38));
1581
1582        // Delete a few characters to restore the overflow part of the insertion line.
1583        cb.add_input_keys(&[Key::Backspace]);
1584        ob = ob.refresh_line(linecol(1, 40), "456789012345678901234567890123456789DABC", yx(1, 37));
1585        cb.add_input_keys(&[Key::Backspace]);
1586        ob = ob.refresh_line(linecol(1, 39), "456789012345678901234567890123456789ABC", yx(1, 36));
1587        cb.add_input_keys(&[Key::Backspace]);
1588        ob = ob.refresh_line(linecol(1, 38), "45678901234567890123456789012345678ABC", yx(1, 35));
1589
1590        // Move back to the beginning of the line to see surrounding lines reappear.
1591        for col in 0u16..35u16 {
1592            cb.add_input_keys(&[Key::ArrowLeft]);
1593            ob = ob.quick_refresh(linecol(1, usize::from(37 - col)), yx(1, 34 - col));
1594        }
1595        cb.add_input_keys(&[Key::ArrowLeft]);
1596        ob = ob.refresh(
1597            linecol(1, 2),
1598            &["", "345678901234567890123456789012345678ABC", "z"],
1599            yx(1, 0),
1600        );
1601        cb.add_input_keys(&[Key::ArrowLeft]);
1602        ob = ob.refresh(
1603            linecol(1, 1),
1604            &["b", "2345678901234567890123456789012345678ABC", "yz"],
1605            yx(1, 0),
1606        );
1607        cb.add_input_keys(&[Key::ArrowLeft]);
1608        ob = ob.refresh(
1609            linecol(1, 0),
1610            &["ab", "12345678901234567890123456789012345678AB", "xyz"],
1611            yx(1, 0),
1612        );
1613
1614        run_editor("ab\n\nxyz\n", "ab\n12345678901234567890123456789012345678ABC\nxyz\n", cb, ob);
1615    }
1616
1617    #[test]
1618    fn test_vertical_scrolling() {
1619        let mut cb = MockConsole::default();
1620        cb.set_size_chars(yx(5, 40));
1621        let mut ob = OutputBuilder::new(yx(5, 40));
1622        ob = ob.refresh(linecol(0, 0), &["abc", "", "d", "e"], yx(0, 0));
1623
1624        // Move to the last line.
1625        cb.add_input_keys(&[Key::ArrowDown]);
1626        ob = ob.quick_refresh(linecol(1, 0), yx(1, 0));
1627        cb.add_input_keys(&[Key::ArrowDown]);
1628        ob = ob.quick_refresh(linecol(2, 0), yx(2, 0));
1629        cb.add_input_keys(&[Key::ArrowDown]);
1630        ob = ob.quick_refresh(linecol(3, 0), yx(3, 0));
1631        cb.add_input_keys(&[Key::ArrowDown]);
1632        ob = ob.refresh(linecol(4, 0), &["", "d", "e", ""], yx(3, 0));
1633        cb.add_input_keys(&[Key::ArrowDown]);
1634        ob = ob.refresh(linecol(5, 0), &["d", "e", "", "fg"], yx(3, 0));
1635        cb.add_input_keys(&[Key::ArrowDown]);
1636        ob = ob.refresh(linecol(6, 0), &["e", "", "fg", "hij"], yx(3, 0));
1637
1638        // Attempting to push through the end of the file does nothing.
1639        cb.add_input_keys(&[Key::ArrowDown]);
1640        ob = ob.quick_refresh(linecol(6, 0), yx(3, 0));
1641
1642        // Go back up to the first line.
1643        cb.add_input_keys(&[Key::ArrowUp]);
1644        ob = ob.quick_refresh(linecol(5, 0), yx(2, 0));
1645        cb.add_input_keys(&[Key::ArrowUp]);
1646        ob = ob.quick_refresh(linecol(4, 0), yx(1, 0));
1647        cb.add_input_keys(&[Key::ArrowUp]);
1648        ob = ob.quick_refresh(linecol(3, 0), yx(0, 0));
1649        cb.add_input_keys(&[Key::ArrowUp]);
1650        ob = ob.refresh(linecol(2, 0), &["d", "e", "", "fg"], yx(0, 0));
1651        cb.add_input_keys(&[Key::ArrowUp]);
1652        ob = ob.refresh(linecol(1, 0), &["", "d", "e", ""], yx(0, 0));
1653        cb.add_input_keys(&[Key::ArrowUp]);
1654        ob = ob.refresh(linecol(0, 0), &["abc", "", "d", "e"], yx(0, 0));
1655
1656        // Attempting to push through the beginning of the file does nothing.
1657        cb.add_input_keys(&[Key::ArrowUp]);
1658        ob = ob.quick_refresh(linecol(0, 0), yx(0, 0));
1659
1660        run_editor("abc\n\nd\ne\n\nfg\nhij\n", "abc\n\nd\ne\n\nfg\nhij\n", cb, ob);
1661    }
1662
1663    #[test]
1664    fn test_vertical_scrolling_when_splitting_last_visible_line() {
1665        let mut cb = MockConsole::default();
1666        cb.set_size_chars(yx(4, 40));
1667        let mut ob = OutputBuilder::new(yx(4, 40));
1668        ob = ob.refresh(linecol(0, 0), &["first", "second", "thirdfourth"], yx(0, 0));
1669
1670        // Move to the desired split point.
1671        cb.add_input_keys(&[Key::ArrowDown]);
1672        ob = ob.quick_refresh(linecol(1, 0), yx(1, 0));
1673        cb.add_input_keys(&[Key::ArrowDown]);
1674        ob = ob.quick_refresh(linecol(2, 0), yx(2, 0));
1675        for i in 0.."third".len() {
1676            cb.add_input_keys(&[Key::ArrowRight]);
1677            ob = ob.quick_refresh(linecol(2, i + 1), yx(2, u16::try_from(i + 1).unwrap()));
1678        }
1679
1680        // Split the last visible line.
1681        cb.add_input_keys(&[Key::NewLine]);
1682        ob = ob.set_dirty();
1683        ob = ob.refresh(linecol(3, 0), &["second", "third", "fourth"], yx(2, 0));
1684
1685        run_editor(
1686            "first\nsecond\nthirdfourth\nfifth\n",
1687            "first\nsecond\nthird\nfourth\nfifth\n",
1688            cb,
1689            ob,
1690        );
1691    }
1692
1693    #[test]
1694    fn test_horizontal_and_vertical_scrolling_when_splitting_last_visible_line() {
1695        let mut cb = MockConsole::default();
1696        cb.set_size_chars(yx(4, 40));
1697        let mut ob = OutputBuilder::new(yx(4, 40));
1698        ob = ob.refresh(
1699            linecol(0, 0),
1700            &["first", "second", "this is a line of text with more than 40"],
1701            yx(0, 0),
1702        );
1703
1704        // Move to the desired split point.
1705        cb.add_input_keys(&[Key::ArrowDown]);
1706        ob = ob.quick_refresh(linecol(1, 0), yx(1, 0));
1707        cb.add_input_keys(&[Key::ArrowDown]);
1708        ob = ob.quick_refresh(linecol(2, 0), yx(2, 0));
1709        for i in 0u16..39u16 {
1710            cb.add_input_keys(&[Key::ArrowRight]);
1711            ob = ob.quick_refresh(linecol(2, usize::from(i + 1)), yx(2, i + 1));
1712        }
1713        cb.add_input_keys(&[Key::ArrowRight]);
1714        ob = ob.refresh(
1715            linecol(2, 40),
1716            &["irst", "econd", "his is a line of text with more than 40 "],
1717            yx(2, 39),
1718        );
1719
1720        // Split the last visible line.
1721        cb.add_input_keys(&[Key::NewLine]);
1722        ob = ob.set_dirty();
1723        ob = ob.refresh(
1724            linecol(3, 0),
1725            &["second", "this is a line of text with more than 40", " characters"],
1726            yx(2, 0),
1727        );
1728
1729        run_editor(
1730            "first\nsecond\nthis is a line of text with more than 40 characters\nfifth\n",
1731            "first\nsecond\nthis is a line of text with more than 40\n characters\nfifth\n",
1732            cb,
1733            ob,
1734        );
1735    }
1736
1737    #[test]
1738    fn test_vertical_scrolling_when_joining_first_visible_line() {
1739        let mut cb = MockConsole::default();
1740        cb.set_size_chars(yx(4, 40));
1741        let mut ob = OutputBuilder::new(yx(4, 40));
1742        ob = ob.refresh(linecol(0, 0), &["first", "second", "third"], yx(0, 0));
1743
1744        // Move down until a couple of lines scroll up.
1745        cb.add_input_keys(&[Key::ArrowDown]);
1746        ob = ob.quick_refresh(linecol(1, 0), yx(1, 0));
1747        cb.add_input_keys(&[Key::ArrowDown]);
1748        ob = ob.quick_refresh(linecol(2, 0), yx(2, 0));
1749        cb.add_input_keys(&[Key::ArrowDown]);
1750        ob = ob.refresh(linecol(3, 0), &["second", "third", "fourth"], yx(2, 0));
1751        cb.add_input_keys(&[Key::ArrowDown]);
1752        ob = ob.refresh(linecol(4, 0), &["third", "fourth", "fifth"], yx(2, 0));
1753
1754        // Move back up to the first visible line, without scrolling.
1755        cb.add_input_keys(&[Key::ArrowUp]);
1756        ob = ob.quick_refresh(linecol(3, 0), yx(1, 0));
1757        cb.add_input_keys(&[Key::ArrowUp]);
1758        ob = ob.quick_refresh(linecol(2, 0), yx(0, 0));
1759
1760        // Join first visible line with previous, which should scroll contents up.
1761        cb.add_input_keys(&[Key::Backspace]);
1762        ob = ob.set_dirty();
1763        ob = ob.refresh(linecol(1, 6), &["secondthird", "fourth", "fifth"], yx(0, 6));
1764
1765        run_editor(
1766            "first\nsecond\nthird\nfourth\nfifth\n",
1767            "first\nsecondthird\nfourth\nfifth\n",
1768            cb,
1769            ob,
1770        );
1771    }
1772
1773    #[test]
1774    fn test_horizontal_and_vertical_scrolling_when_joining_first_visible_line() {
1775        let mut cb = MockConsole::default();
1776        cb.set_size_chars(yx(4, 40));
1777        let mut ob = OutputBuilder::new(yx(4, 40));
1778        ob = ob.refresh(
1779            linecol(0, 0),
1780            &["first", "this is a line of text with more than 40", "third"],
1781            yx(0, 0),
1782        );
1783
1784        // Move down until a couple of lines scroll up.
1785        cb.add_input_keys(&[Key::ArrowDown]);
1786        ob = ob.quick_refresh(linecol(1, 0), yx(1, 0));
1787        cb.add_input_keys(&[Key::ArrowDown]);
1788        ob = ob.quick_refresh(linecol(2, 0), yx(2, 0));
1789        cb.add_input_keys(&[Key::ArrowDown]);
1790        ob = ob.refresh(
1791            linecol(3, 0),
1792            &["this is a line of text with more than 40", "third", "fourth"],
1793            yx(2, 0),
1794        );
1795        cb.add_input_keys(&[Key::ArrowDown]);
1796        ob = ob.refresh(linecol(4, 0), &["third", "fourth", "quite a long line"], yx(2, 0));
1797
1798        // Move back up to the first visible line, without scrolling.
1799        cb.add_input_keys(&[Key::ArrowUp]);
1800        ob = ob.quick_refresh(linecol(3, 0), yx(1, 0));
1801        cb.add_input_keys(&[Key::ArrowUp]);
1802        ob = ob.quick_refresh(linecol(2, 0), yx(0, 0));
1803
1804        // Join first visible line with previous, which should scroll contents up and right.
1805        cb.add_input_keys(&[Key::Backspace]);
1806        ob = ob.set_dirty();
1807        ob = ob.refresh(
1808            linecol(1, 51),
1809            &["ne of text with more than 40 characterst", "", " line"],
1810            yx(0, 39),
1811        );
1812
1813        run_editor(
1814            "first\nthis is a line of text with more than 40 characters\nthird\nfourth\nquite a long line\n",
1815            "first\nthis is a line of text with more than 40 charactersthird\nfourth\nquite a long line\n",
1816            cb,
1817            ob,
1818        );
1819    }
1820
1821    #[test]
1822    fn test_narrow_console() {
1823        let mut cb = MockConsole::default();
1824        cb.set_size_chars(yx(10, 25));
1825        let mut ob = OutputBuilder::new(yx(10, 25));
1826        // The key in this test is to verify that writing the status line doesn't trigger invalid
1827        // operations (like integer underflows).  See the logic in refresh_status for details.
1828        ob = ob.refresh(linecol(0, 0), &[""], yx(0, 0));
1829
1830        run_editor("", "\n", cb, ob);
1831    }
1832
1833    #[test]
1834    fn test_very_narrow_console() {
1835        let mut cb = MockConsole::default();
1836        cb.set_size_chars(yx(10, 5));
1837        let mut ob = OutputBuilder::new(yx(10, 5));
1838        // The key in this test is to verify that writing the status line doesn't trigger invalid
1839        // operations (like integer underflows).  See the logic in refresh_status for details.
1840        ob = ob.refresh(linecol(0, 0), &[""], yx(0, 0));
1841
1842        run_editor("", "\n", cb, ob);
1843    }
1844}