requestty_ui/backend/
test_backend.rs

1use std::{
2    io::{self, Write},
3    ops,
4};
5
6use super::{Backend, ClearType, MoveDirection, Size};
7use crate::{
8    layout::Layout,
9    style::{Attributes, Color},
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13struct Cell {
14    value: Option<char>,
15    fg: Color,
16    bg: Color,
17    attributes: Attributes,
18}
19
20impl Default for Cell {
21    fn default() -> Self {
22        Self {
23            value: None,
24            fg: Color::Reset,
25            bg: Color::Reset,
26            attributes: Attributes::empty(),
27        }
28    }
29}
30
31#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
32struct Cursor {
33    x: u16,
34    y: u16,
35}
36
37impl Cursor {
38    fn to_linear(self, width: u16) -> usize {
39        (self.x + self.y * width) as usize
40    }
41}
42
43impl From<Cursor> for (u16, u16) {
44    fn from(c: Cursor) -> Self {
45        (c.x, c.y)
46    }
47}
48
49/// A backend that can be used for tests.
50///
51/// When asserting equality, it is recommended to use [`TestBackend::assert_eq`] or
52/// [`assert_backend_snapshot`] instead of [`assert_eq`].
53///
54/// [`assert_backend_snapshot`]: crate::assert_backend_snapshot
55#[derive(Debug, Clone)]
56pub struct TestBackend {
57    cells: Vec<Cell>,
58    cursor: Cursor,
59    size: Size,
60    raw: bool,
61    hidden_cursor: bool,
62    current_fg: Color,
63    current_bg: Color,
64    current_attributes: Attributes,
65    viewport_start: usize,
66}
67
68impl PartialEq for TestBackend {
69    /// Visual equality to another backend. This means that if the cells of both backends were
70    /// rendered on a terminal, they would look the same. It however does not mean, that the hidden
71    /// scrollback buffer is the same, or the current attributes are the same, or event the cursor
72    /// position if it is hidden.
73    fn eq(&self, other: &Self) -> bool {
74        self.viewport() == other.viewport()
75            && self.size == other.size
76            && self.hidden_cursor == other.hidden_cursor
77            && (self.hidden_cursor || self.cursor == other.cursor)
78    }
79}
80
81impl Eq for TestBackend {}
82
83impl TestBackend {
84    /// Creates a new `TestBackend`
85    pub fn new(size: Size) -> Self {
86        Self::new_with_layout(size, Layout::new(0, size))
87    }
88
89    /// Creates a new `TestBackend` with the cursor starting at the offsets given by the layout.
90    pub fn new_with_layout(size: Size, layout: Layout) -> Self {
91        let mut this = Self {
92            cells: [Cell::default()].repeat(size.area() as usize),
93            cursor: Cursor::default(),
94            size,
95            raw: false,
96            hidden_cursor: false,
97            current_fg: Color::Reset,
98            current_bg: Color::Reset,
99            current_attributes: Attributes::empty(),
100            viewport_start: 0,
101        };
102
103        this.move_x(layout.line_offset + layout.offset_x);
104        this.move_y(layout.offset_y);
105
106        this
107    }
108
109    /// Creates a new `TestBackend` from the lines. There must be `<= size.height` lines, and
110    /// `<= size.width` chars per line.
111    ///
112    /// It is not necessary to fill the lines so that it matches the dimensions of size exactly.
113    /// Padding will be added to the end as required.
114    ///
115    /// # Panics
116    ///
117    /// It panics if there are more than `size.height` lines or more than `size.width` chars per
118    /// line.
119    pub fn from_lines(lines: &[&str], size: Size) -> Self {
120        let mut backend = Self::new(size);
121
122        assert!(lines.len() <= size.height as usize);
123        let last_i = lines.len() - 1;
124
125        for (i, line) in lines.iter().enumerate() {
126            for c in line.chars() {
127                assert!(backend.cursor.x + 1 < backend.size.width);
128                backend.put_char(c);
129            }
130            if i < last_i {
131                backend.move_x(0);
132                backend.add_y(1);
133            }
134        }
135
136        backend
137    }
138
139    /// Clears all the cells and moves the cursor to the offsets given by the layout.
140    pub fn reset_with_layout(&mut self, layout: Layout) {
141        self.clear_range(..);
142        self.move_x(layout.offset_x + layout.line_offset);
143        self.move_y(layout.offset_y);
144    }
145
146    fn viewport(&self) -> &[Cell] {
147        &self.cells[self.viewport_start..(self.viewport_start + self.size.area() as usize)]
148    }
149
150    fn move_x(&mut self, x: u16) {
151        // wrapping_sub to allow testing 0 sized terminals
152        self.cursor.x = x.min(self.size.width.wrapping_sub(1));
153    }
154
155    fn move_y(&mut self, y: u16) {
156        // wrapping_sub to allow testing 0 sized terminals
157        self.cursor.y = y.min(self.size.height.wrapping_sub(1));
158    }
159
160    fn add_x(&mut self, x: u16) {
161        let x = self.cursor.x + x;
162        let dy = x / self.size.width;
163        self.cursor.x = x % self.size.width;
164        self.move_y(self.cursor.y + dy);
165    }
166
167    fn sub_x(&mut self, x: u16) {
168        self.cursor.x = self.cursor.x.saturating_sub(x);
169    }
170
171    fn add_y(&mut self, y: u16) {
172        self.move_y(self.cursor.y + y)
173    }
174
175    fn sub_y(&mut self, y: u16) {
176        self.cursor.y = self.cursor.y.saturating_sub(y);
177    }
178
179    fn cell_i(&self) -> usize {
180        self.viewport_start + self.cursor.to_linear(self.size.width)
181    }
182
183    fn cell(&mut self) -> &mut Cell {
184        let i = self.cell_i();
185        &mut self.cells[i]
186    }
187
188    fn clear_range<R: ops::RangeBounds<usize>>(&mut self, range: R) {
189        let start = match range.start_bound() {
190            ops::Bound::Included(&start) => start,
191            ops::Bound::Excluded(start) => start.checked_add(1).unwrap(),
192            ops::Bound::Unbounded => 0,
193        };
194
195        let end = match range.end_bound() {
196            ops::Bound::Included(end) => end.checked_add(1).unwrap(),
197            ops::Bound::Excluded(&end) => end,
198            ops::Bound::Unbounded => self.cells.len(),
199        };
200
201        self.cells[start..end]
202            .iter_mut()
203            .for_each(|c| *c = Cell::default());
204    }
205
206    fn put_char(&mut self, c: char) {
207        match c {
208            '\n' => {
209                self.add_y(1);
210                if !self.raw {
211                    self.cursor.x = 0;
212                }
213            }
214            '\r' => self.cursor.x = 0,
215            '\t' => {
216                let x = 8 + self.cursor.x - (self.cursor.x % 8);
217                if x >= self.size.width && self.cursor.y < self.size.width - 1 {
218                    self.cursor.x = 0;
219                    self.cursor.y += 1;
220                } else {
221                    self.move_x(x);
222                }
223            }
224            c => {
225                self.cell().value = Some(c);
226                self.cell().attributes = self.current_attributes;
227                self.cell().fg = self.current_fg;
228                self.cell().bg = self.current_bg;
229                self.add_x(1);
230            }
231        }
232    }
233
234    #[cfg(any(feature = "crossterm", feature = "termion"))]
235    fn assertion_failed(&self, other: &Self) {
236        panic!(
237            r#"assertion failed: `(left == right)`
238 left:
239{}
240right:
241{}
242"#,
243            self, other
244        );
245    }
246
247    #[cfg(not(any(feature = "crossterm", feature = "termion")))]
248    fn assertion_failed(&self, other: &Self) {
249        panic!(
250            r#"assertion failed: `(left == right)`
251 left:
252`TestBackend` {:p}
253right:
254`TestBackend` {:p}
255
256Enable any of the default backends to view what the `TestBackend`s looked like
257"#,
258            self, other
259        );
260    }
261
262    /// Asserts that two `TestBackend`s are equal to each other, otherwise it panics printing what
263    /// the backend would look like.
264    pub fn assert_eq(&self, other: &Self) {
265        if *self != *other {
266            self.assertion_failed(other);
267        }
268    }
269}
270
271impl Write for TestBackend {
272    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
273        std::str::from_utf8(buf)
274            .map_err(|_| io::ErrorKind::InvalidInput)?
275            .chars()
276            .for_each(|c| self.put_char(c));
277
278        Ok(buf.len())
279    }
280
281    fn flush(&mut self) -> io::Result<()> {
282        Ok(())
283    }
284}
285
286impl super::Backend for TestBackend {
287    fn enable_raw_mode(&mut self) -> io::Result<()> {
288        self.raw = true;
289        Ok(())
290    }
291
292    fn disable_raw_mode(&mut self) -> io::Result<()> {
293        self.raw = false;
294        Ok(())
295    }
296
297    fn hide_cursor(&mut self) -> io::Result<()> {
298        self.hidden_cursor = true;
299        Ok(())
300    }
301
302    fn show_cursor(&mut self) -> io::Result<()> {
303        self.hidden_cursor = false;
304        Ok(())
305    }
306
307    fn get_cursor_pos(&mut self) -> io::Result<(u16, u16)> {
308        Ok(self.cursor.into())
309    }
310
311    fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
312        self.move_x(x);
313        self.move_y(y);
314        Ok(())
315    }
316
317    fn move_cursor(&mut self, direction: MoveDirection) -> io::Result<()> {
318        match direction {
319            MoveDirection::Up(n) => self.sub_y(n),
320            MoveDirection::Down(n) => self.add_y(n),
321            MoveDirection::Left(n) => self.sub_x(n),
322            MoveDirection::Right(n) => self.add_y(n),
323            MoveDirection::NextLine(n) => {
324                self.cursor.x = 0;
325                self.add_y(n);
326            }
327            MoveDirection::Column(n) => self.move_x(n),
328            MoveDirection::PrevLine(n) => {
329                self.cursor.x = 0;
330                self.sub_y(n);
331            }
332        }
333        Ok(())
334    }
335
336    fn scroll(&mut self, dist: i16) -> io::Result<()> {
337        if dist.is_positive() {
338            self.viewport_start = self
339                .viewport_start
340                .saturating_sub(dist as usize * self.size.width as usize);
341        } else {
342            self.viewport_start += (-dist as usize) * self.size.width as usize;
343            let new_len = self.viewport_start + self.size.area() as usize;
344
345            if new_len > self.cells.len() {
346                self.cells.resize_with(new_len, Cell::default)
347            };
348        }
349        Ok(())
350    }
351
352    fn set_attributes(&mut self, attributes: Attributes) -> io::Result<()> {
353        self.current_attributes = attributes;
354        Ok(())
355    }
356
357    fn set_fg(&mut self, color: Color) -> io::Result<()> {
358        self.current_fg = color;
359        Ok(())
360    }
361
362    fn set_bg(&mut self, color: Color) -> io::Result<()> {
363        self.current_bg = color;
364        Ok(())
365    }
366
367    fn clear(&mut self, clear_type: ClearType) -> io::Result<()> {
368        match clear_type {
369            ClearType::All => self.clear_range(..),
370            ClearType::FromCursorDown => self.clear_range(self.cell_i()..),
371            ClearType::FromCursorUp => self.clear_range(..=self.cell_i()),
372            ClearType::CurrentLine => {
373                let s = (self.cursor.y * self.size.width) as usize;
374                let e = ((self.cursor.y + 1) * self.size.width) as usize;
375                self.clear_range(s..e)
376            }
377            ClearType::UntilNewLine => {
378                let e = ((self.cursor.y + 1) * self.size.width) as usize;
379                self.clear_range(self.cell_i()..e)
380            }
381        }
382        Ok(())
383    }
384
385    fn size(&self) -> io::Result<Size> {
386        Ok(self.size)
387    }
388}
389
390#[cfg(any(feature = "crossterm", feature = "termion"))]
391#[cfg_attr(docsrs, doc(cfg(any(feature = "crossterm", feature = "termion"))))]
392impl std::fmt::Display for TestBackend {
393    /// Writes all the cells of the `TestBackend` using [`write_to_buf`].
394    ///
395    /// A screenshot of what the printed output looks like:
396    ///
397    /// ![](https://raw.githubusercontent.com/lutetium-vanadium/requestty/master/assets/test-backend-rendered.png)
398    ///
399    /// [`write_to_buf`]: TestBackend::write_to_buf
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        let mut buf = Vec::with_capacity(self.size.area() as usize);
402
403        if let Err(e) = self.write_to_buf(&mut buf) {
404            return write!(f, "<could not render TestBackend: {}>", e);
405        }
406
407        match std::str::from_utf8(&buf) {
408            Ok(s) => write!(f, "{}", s),
409            Err(e) => write!(f, "<could not render TestBackend: {}>", e),
410        }
411    }
412}
413
414fn map_reset(c: Color, to: Color) -> Color {
415    match c {
416        Color::Reset => to,
417        c => c,
418    }
419}
420
421impl TestBackend {
422    /// Writes all the cells of the `TestBackend` to the given backend.
423    ///
424    /// A screenshot of what the printed output looks like:
425    ///
426    /// ![](https://raw.githubusercontent.com/lutetium-vanadium/requestty/master/assets/test-backend-rendered.png)
427    pub fn write_to_backend<B: Backend>(&self, mut backend: B) -> io::Result<()> {
428        let mut fg = Color::Reset;
429        let mut bg = Color::Reset;
430        let mut attributes = Attributes::empty();
431
432        let cursor = if self.hidden_cursor {
433            usize::MAX
434        } else {
435            self.cursor.to_linear(self.size.width) as usize
436        };
437
438        let width = self.size.width as usize;
439
440        let symbol_set = crate::symbols::current();
441
442        write!(backend, "{}", symbol_set.box_top_left)?;
443        for _ in 0..self.size.width {
444            write!(backend, "{}", symbol_set.box_horizontal)?;
445        }
446        writeln!(backend, "{}", symbol_set.box_top_right)?;
447
448        for (i, cell) in self.viewport().iter().enumerate() {
449            if i % width == 0 {
450                write!(backend, "{}", symbol_set.box_vertical)?;
451            }
452
453            if cell.attributes != attributes {
454                backend.set_attributes(cell.attributes)?;
455                attributes = cell.attributes;
456            }
457
458            let (cell_fg, cell_bg) = if i == cursor {
459                (
460                    map_reset(cell.bg, Color::Black),
461                    map_reset(cell.fg, Color::Grey),
462                )
463            } else {
464                (cell.fg, cell.bg)
465            };
466
467            if cell_fg != fg {
468                backend.set_fg(cell_fg)?;
469                fg = cell_fg;
470            }
471            if cell_bg != bg {
472                backend.set_bg(cell_bg)?;
473                bg = cell_bg;
474            }
475
476            write!(backend, "{}", cell.value.unwrap_or(' '))?;
477
478            if (i + 1) % width == 0 {
479                if !attributes.is_empty() {
480                    backend.set_attributes(Attributes::empty())?;
481                    attributes = Attributes::empty();
482                }
483                if fg != Color::Reset {
484                    fg = Color::Reset;
485                    backend.set_fg(fg)?;
486                }
487                if bg != Color::Reset {
488                    bg = Color::Reset;
489                    backend.set_bg(bg)?;
490                }
491                writeln!(backend, "{}", symbol_set.box_vertical)?;
492            }
493        }
494
495        write!(backend, "{}", symbol_set.box_bottom_left)?;
496        for _ in 0..self.size.width {
497            write!(backend, "{}", symbol_set.box_horizontal)?;
498        }
499        write!(backend, "{}", symbol_set.box_bottom_right)?;
500
501        backend.flush()
502    }
503
504    /// Writes all the cells of the `TestBackend` with the default backend (see [`get_backend`]).
505    ///
506    /// A screenshot of what the printed output looks like:
507    ///
508    /// ![](https://raw.githubusercontent.com/lutetium-vanadium/requestty/master/assets/test-backend-rendered.png)
509    ///
510    /// [`get_backend`]: crate::backend::get_backend
511    #[cfg(any(feature = "crossterm", feature = "termion"))]
512    #[cfg_attr(docsrs, doc(cfg(any(feature = "crossterm", feature = "termion"))))]
513    pub fn write_to_buf<W: Write>(&self, buf: W) -> io::Result<()> {
514        self.write_to_backend(super::get_backend(buf))
515    }
516}