line_ui/
render.rs

1/*
2 * Copyright (c) 2025 Jasmine Tai. All rights reserved.
3 */
4
5use std::io::{self, Write};
6
7use termion::style::Reset;
8use termion::{clear, cursor};
9use unicode_width::UnicodeWidthStr;
10
11use crate::Style;
12use crate::element::Element;
13
14/// A chunk of text with a constant style to be rendered.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct RenderChunk<'s> {
17    /// The content of this chunk.
18    pub(crate) value: &'s str,
19    /// The width of this chunk.
20    pub(crate) width: usize,
21    /// The style of this chunk.
22    pub(crate) style: Style,
23    /// Whether to display the cursor at the start of this chunk. If this is
24    /// true, then `value` must be `""`, `width` must be `0`, and `style` must
25    /// be `Style::EMPTY`.
26    pub(crate) cursor: bool,
27}
28
29impl<'s> RenderChunk<'s> {
30    pub const CURSOR: RenderChunk<'static> = RenderChunk {
31        value: "",
32        width: 0,
33        style: Style::EMPTY,
34        cursor: true,
35    };
36
37    pub fn new(value: &'s str, style: Style) -> Self {
38        RenderChunk::with_known_width(value, value.width(), style)
39    }
40
41    pub(crate) fn with_known_width(value: &'s str, width: usize, style: Style) -> Self {
42        debug_assert_eq!(value.width(), width);
43        RenderChunk {
44            value,
45            width,
46            style,
47            cursor: false,
48        }
49    }
50}
51
52impl<'s> From<&'s str> for RenderChunk<'s> {
53    fn from(value: &'s str) -> Self {
54        RenderChunk::new(value, Style::EMPTY)
55    }
56}
57
58/// A struct that outputs lines to a [writer](Write).
59pub struct Renderer<W: Write> {
60    pub(crate) writer: W,
61    lines_rendered: u16,
62    desired_cursor: Option<(u16, u16)>,
63}
64
65impl<W: Write> Renderer<W> {
66    /// Creates a new [`Renderer`] that writes to the given writer.
67    pub fn new(writer: W) -> Self {
68        Renderer {
69            writer,
70            lines_rendered: 0,
71            desired_cursor: None,
72        }
73    }
74
75    /// Resets the cursor position. This should be called before
76    /// [`render`](Self::render).
77    pub fn reset(&mut self) -> io::Result<&mut Self> {
78        // Reset the cursor to the top-left.
79        let current_cursor_line = match self.desired_cursor {
80            // If there's a desired cursor position, the cursor is there.
81            Some((line, _)) => line,
82            // Otherwise, it's the last line rendered.
83            None => self.lines_rendered.saturating_sub(1),
84        };
85        if current_cursor_line != 0 {
86            write!(self.writer, "{}", cursor::Up(current_cursor_line))?;
87        }
88        write!(self.writer, "\r")?;
89
90        // Reset the renderer's state.
91        self.lines_rendered = 0;
92        self.desired_cursor = None;
93        Ok(self)
94    }
95
96    /// Clears the rendering area, resetting the terminal back to its initial
97    /// state.
98    ///
99    /// Note that this function is automatically called when the [`Renderer`] is
100    /// [dropped](Drop).
101    pub fn clear(&mut self) -> io::Result<()> {
102        self.reset()?;
103        write!(self.writer, "{}{}", clear::AfterCursor, cursor::Show)
104    }
105
106    /// Renders a line.
107    pub fn render<E: Element>(&mut self, line: E) -> io::Result<&mut Self> {
108        // If this isn't the first line, then move to the next line.
109        if self.lines_rendered != 0 {
110            write!(self.writer, "\n\r")?;
111        }
112        // Render each chunk.
113        let mut column = 0;
114        for chunk in line.render() {
115            if chunk.cursor {
116                debug_assert_eq!(chunk.value, "");
117                debug_assert_eq!(chunk.width, 0);
118                self.desired_cursor = Some((self.lines_rendered, column as u16));
119            } else {
120                write!(self.writer, "{}{}{Reset}", chunk.style, chunk.value)?;
121                column += chunk.width;
122            }
123        }
124        self.lines_rendered += 1;
125        Ok(self)
126    }
127
128    /// Finishes rendering. This should be called after [`render`](Self::render)
129    /// and before polling inputs.
130    pub fn finish(&mut self) -> io::Result<()> {
131        if let Some((line, column)) = self.desired_cursor {
132            let up = self.lines_rendered - line - 1;
133            if up != 0 {
134                write!(self.writer, "{}", cursor::Up(up))?;
135            }
136            write!(self.writer, "\r")?;
137            if column != 0 {
138                write!(self.writer, "{}", cursor::Right(column))?;
139            }
140            write!(self.writer, "{}", cursor::Show)?;
141        } else {
142            write!(self.writer, "{}", cursor::Hide)?;
143        }
144        self.writer.flush()
145    }
146}
147
148impl<W: Write> Drop for Renderer<W> {
149    fn drop(&mut self) {
150        let _ = self.clear();
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use crate::element::{Cursor, IntoElement};
157
158    use super::*;
159
160    #[test]
161    fn empty() -> io::Result<()> {
162        let mut r = Renderer::new(vec![]);
163        for _ in 0..3 {
164            r.writer.clear();
165            r.reset()?.finish()?;
166            assert_eq!(r.writer, b"\r\x1b[?25l");
167        }
168        Ok(())
169    }
170
171    #[test]
172    fn one_line() -> io::Result<()> {
173        let mut r = Renderer::new(vec![]);
174        for _ in 0..3 {
175            r.writer.clear();
176            r.reset()?.render("trans rights".into_element())?.finish()?;
177            assert_eq!(r.writer, b"\rtrans rights\x1b[m\x1b[?25l");
178        }
179        Ok(())
180    }
181
182    #[test]
183    fn two_lines() -> io::Result<()> {
184        let mut r = Renderer::new(vec![]);
185        r.reset()?
186            .render("trans rights".into_element())?
187            .render("enby rights".into_element())?
188            .finish()?;
189        assert_eq!(
190            r.writer,
191            b"\rtrans rights\x1b[m\n\renby rights\x1b[m\x1b[?25l",
192        );
193
194        for _ in 0..3 {
195            r.writer.clear();
196            r.reset()?
197                .render("trans rights".into_element())?
198                .render("enby rights".into_element())?
199                .finish()?;
200            assert_eq!(
201                r.writer,
202                b"\x1b[1A\rtrans rights\x1b[m\n\renby rights\x1b[m\x1b[?25l",
203            );
204        }
205        Ok(())
206    }
207
208    #[test]
209    fn drop() {
210        let mut out = vec![];
211        Renderer::new(&mut out);
212        assert_eq!(out, b"\r\x1b[J\x1b[?25h");
213    }
214
215    #[test]
216    fn cursor_at_start_of_last_line() -> io::Result<()> {
217        let mut r = Renderer::new(vec![]);
218        r.reset()?
219            .render("trans rights".into_element())?
220            .render((Cursor, "enby rights".into_element()))?
221            .finish()?;
222        assert_eq!(
223            r.writer,
224            b"\rtrans rights\x1b[m\n\renby rights\x1b[m\r\x1b[?25h",
225        );
226        Ok(())
227    }
228
229    #[test]
230    fn cursor_in_last_line() -> io::Result<()> {
231        let mut r = Renderer::new(vec![]);
232        r.reset()?
233            .render("trans rights".into_element())?
234            .render(("enby ".into_element(), Cursor, "rights".into_element()))?
235            .finish()?;
236        assert_eq!(
237            r.writer,
238            b"\rtrans rights\x1b[m\n\renby \x1b[mrights\x1b[m\r\x1b[5C\x1b[?25h",
239        );
240        Ok(())
241    }
242
243    #[test]
244    fn cursor_in_previous_line() -> io::Result<()> {
245        let mut r = Renderer::new(vec![]);
246        r.reset()?
247            .render(("trans rights".into_element(), Cursor))?
248            .render("enby rights".into_element())?
249            .finish()?;
250        assert_eq!(
251            r.writer,
252            b"\rtrans rights\x1b[m\n\renby rights\x1b[m\x1b[1A\r\x1b[12C\x1b[?25h",
253        );
254        Ok(())
255    }
256}