requestty_ui/backend/
test_backend.rs

1use std::{
2    io::{self, Write},
3    ops,
4};
5
6use super::{Backend, ClearType, DisplayBackend, 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 DisplayBackend for TestBackend {
287    fn set_attributes(&mut self, attributes: Attributes) -> io::Result<()> {
288        self.current_attributes = attributes;
289        Ok(())
290    }
291
292    fn set_fg(&mut self, color: Color) -> io::Result<()> {
293        self.current_fg = color;
294        Ok(())
295    }
296
297    fn set_bg(&mut self, color: Color) -> io::Result<()> {
298        self.current_bg = color;
299        Ok(())
300    }
301}
302
303impl Backend for TestBackend {
304    fn enable_raw_mode(&mut self) -> io::Result<()> {
305        self.raw = true;
306        Ok(())
307    }
308
309    fn disable_raw_mode(&mut self) -> io::Result<()> {
310        self.raw = false;
311        Ok(())
312    }
313
314    fn hide_cursor(&mut self) -> io::Result<()> {
315        self.hidden_cursor = true;
316        Ok(())
317    }
318
319    fn show_cursor(&mut self) -> io::Result<()> {
320        self.hidden_cursor = false;
321        Ok(())
322    }
323
324    fn get_cursor_pos(&mut self) -> io::Result<(u16, u16)> {
325        Ok(self.cursor.into())
326    }
327
328    fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
329        self.move_x(x);
330        self.move_y(y);
331        Ok(())
332    }
333
334    fn move_cursor(&mut self, direction: MoveDirection) -> io::Result<()> {
335        match direction {
336            MoveDirection::Up(n) => self.sub_y(n),
337            MoveDirection::Down(n) => self.add_y(n),
338            MoveDirection::Left(n) => self.sub_x(n),
339            MoveDirection::Right(n) => self.add_y(n),
340            MoveDirection::NextLine(n) => {
341                self.cursor.x = 0;
342                self.add_y(n);
343            }
344            MoveDirection::Column(n) => self.move_x(n),
345            MoveDirection::PrevLine(n) => {
346                self.cursor.x = 0;
347                self.sub_y(n);
348            }
349        }
350        Ok(())
351    }
352
353    fn scroll(&mut self, dist: i16) -> io::Result<()> {
354        if dist.is_positive() {
355            self.viewport_start = self
356                .viewport_start
357                .saturating_sub(dist as usize * self.size.width as usize);
358        } else {
359            self.viewport_start += (-dist as usize) * self.size.width as usize;
360            let new_len = self.viewport_start + self.size.area() as usize;
361
362            if new_len > self.cells.len() {
363                self.cells.resize_with(new_len, Cell::default)
364            };
365        }
366        Ok(())
367    }
368
369    fn clear(&mut self, clear_type: ClearType) -> io::Result<()> {
370        match clear_type {
371            ClearType::All => self.clear_range(..),
372            ClearType::FromCursorDown => self.clear_range(self.cell_i()..),
373            ClearType::FromCursorUp => self.clear_range(..=self.cell_i()),
374            ClearType::CurrentLine => {
375                let s = (self.cursor.y * self.size.width) as usize;
376                let e = ((self.cursor.y + 1) * self.size.width) as usize;
377                self.clear_range(s..e)
378            }
379            ClearType::UntilNewLine => {
380                let e = ((self.cursor.y + 1) * self.size.width) as usize;
381                self.clear_range(self.cell_i()..e)
382            }
383        }
384        Ok(())
385    }
386
387    fn size(&self) -> io::Result<Size> {
388        Ok(self.size)
389    }
390}
391
392#[cfg(any(feature = "crossterm", feature = "termion"))]
393#[cfg_attr(docsrs, doc(cfg(any(feature = "crossterm", feature = "termion"))))]
394impl std::fmt::Display for TestBackend {
395    /// Writes all the cells of the `TestBackend` using [`write_to_buf`].
396    ///
397    /// A screenshot of what the printed output looks like:
398    ///
399    /// ![](https://raw.githubusercontent.com/lutetium-vanadium/requestty/master/assets/test-backend-rendered.png)
400    ///
401    /// [`write_to_buf`]: TestBackend::write_to_buf
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        let mut buf = Vec::with_capacity(self.size.area() as usize);
404
405        if let Err(e) = self.write_to_buf(&mut buf) {
406            return write!(f, "<could not render TestBackend: {}>", e);
407        }
408
409        match std::str::from_utf8(&buf) {
410            Ok(s) => write!(f, "{}", s),
411            Err(e) => write!(f, "<could not render TestBackend: {}>", e),
412        }
413    }
414}
415
416fn map_reset(c: Color, to: Color) -> Color {
417    match c {
418        Color::Reset => to,
419        c => c,
420    }
421}
422
423impl TestBackend {
424    /// Writes all the cells of the `TestBackend` to the given backend.
425    ///
426    /// A screenshot of what the printed output looks like:
427    ///
428    /// ![](https://raw.githubusercontent.com/lutetium-vanadium/requestty/master/assets/test-backend-rendered.png)
429    pub fn write_to_backend<B: DisplayBackend>(&self, mut backend: B) -> io::Result<()> {
430        let mut fg = Color::Reset;
431        let mut bg = Color::Reset;
432        let mut attributes = Attributes::empty();
433
434        let cursor = if self.hidden_cursor {
435            usize::MAX
436        } else {
437            self.cursor.to_linear(self.size.width)
438        };
439
440        let width = self.size.width as usize;
441
442        let symbol_set = crate::symbols::current();
443
444        write!(backend, "{}", symbol_set.box_top_left)?;
445        for _ in 0..self.size.width {
446            write!(backend, "{}", symbol_set.box_horizontal)?;
447        }
448        writeln!(backend, "{}", symbol_set.box_top_right)?;
449
450        for (i, cell) in self.viewport().iter().enumerate() {
451            if i % width == 0 {
452                write!(backend, "{}", symbol_set.box_vertical)?;
453            }
454
455            if cell.attributes != attributes {
456                backend.set_attributes(cell.attributes)?;
457                attributes = cell.attributes;
458            }
459
460            let (cell_fg, cell_bg) = if i == cursor {
461                (
462                    map_reset(cell.bg, Color::Black),
463                    map_reset(cell.fg, Color::Grey),
464                )
465            } else {
466                (cell.fg, cell.bg)
467            };
468
469            if cell_fg != fg {
470                backend.set_fg(cell_fg)?;
471                fg = cell_fg;
472            }
473            if cell_bg != bg {
474                backend.set_bg(cell_bg)?;
475                bg = cell_bg;
476            }
477
478            write!(backend, "{}", cell.value.unwrap_or(' '))?;
479
480            if (i + 1) % width == 0 {
481                if !attributes.is_empty() {
482                    backend.set_attributes(Attributes::empty())?;
483                    attributes = Attributes::empty();
484                }
485                if fg != Color::Reset {
486                    fg = Color::Reset;
487                    backend.set_fg(fg)?;
488                }
489                if bg != Color::Reset {
490                    bg = Color::Reset;
491                    backend.set_bg(bg)?;
492                }
493                writeln!(backend, "{}", symbol_set.box_vertical)?;
494            }
495        }
496
497        write!(backend, "{}", symbol_set.box_bottom_left)?;
498        for _ in 0..self.size.width {
499            write!(backend, "{}", symbol_set.box_horizontal)?;
500        }
501        write!(backend, "{}", symbol_set.box_bottom_right)?;
502
503        backend.flush()
504    }
505
506    /// Writes all the cells of the `TestBackend` with the default backend (see [`get_backend`]).
507    ///
508    /// A screenshot of what the printed output looks like:
509    ///
510    /// ![](https://raw.githubusercontent.com/lutetium-vanadium/requestty/master/assets/test-backend-rendered.png)
511    ///
512    /// [`get_backend`]: crate::backend::get_backend
513    #[cfg(any(feature = "crossterm", feature = "termion"))]
514    #[cfg_attr(docsrs, doc(cfg(any(feature = "crossterm", feature = "termion"))))]
515    pub fn write_to_buf<W: Write>(&self, buf: W) -> io::Result<()> {
516        #[cfg(feature = "crossterm")]
517        return self.write_to_backend(super::CrosstermBackend::new(buf));
518        #[cfg(all(not(feature = "crossterm"), feature = "termion"))]
519        return self.write_to_backend(super::TermionDisplayBackend::new(buf));
520    }
521}