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    is_dirty: bool, // flag for debugging
64}
65
66impl<W: Write> Renderer<W> {
67    /// Creates a new [`Renderer`] that writes to the given writer.
68    pub fn new(writer: W) -> Self {
69        Renderer {
70            writer,
71            lines_rendered: 0,
72            desired_cursor: None,
73            is_dirty: false,
74        }
75    }
76
77    /// Resets the renderer's state.
78    fn reset_state(&mut self) {
79        self.lines_rendered = 0;
80        self.desired_cursor = None;
81        self.is_dirty = false;
82    }
83
84    /// Resets the cursor position, allowing rendering to start over.
85    pub fn reset(&mut self) -> io::Result<&mut Self> {
86        assert!(!self.is_dirty, "finalize() must be called after rendering");
87        // Reset the cursor to the top-left.
88        let current_cursor_line = match self.desired_cursor {
89            // If there's a desired cursor position, the cursor is there.
90            Some((line, _)) => line,
91            // Otherwise, it's the last line rendered.
92            None => self.lines_rendered.saturating_sub(1),
93        };
94        if current_cursor_line != 0 {
95            write!(self.writer, "{}", cursor::Up(current_cursor_line))?;
96        }
97        write!(self.writer, "\r")?;
98
99        self.reset_state();
100        Ok(self)
101    }
102
103    /// Clears the UI, resetting the terminal back to its initial state.
104    ///
105    /// Note that this method is automatically called when the `Renderer` is
106    /// [dropped](Drop).
107    pub fn clear(&mut self) -> io::Result<()> {
108        assert!(!self.is_dirty, "finalize() must be called after rendering");
109        self.reset()?;
110        write!(self.writer, "{}{}", clear::AfterCursor, cursor::Show)
111    }
112
113    /// Renders a line.
114    pub fn render<E: Element>(&mut self, line: E) -> io::Result<&mut Self> {
115        self.is_dirty = true;
116        // If this isn't the first line, then move to the next line.
117        if self.lines_rendered != 0 {
118            write!(self.writer, "\n\r")?;
119        }
120        // Render each chunk.
121        let mut column = 0;
122        for chunk in line.render() {
123            if chunk.cursor {
124                debug_assert_eq!(chunk.value, "");
125                debug_assert_eq!(chunk.width, 0);
126                self.desired_cursor = Some((self.lines_rendered, column as u16));
127            } else {
128                write!(self.writer, "{}{}{Reset}", chunk.style, chunk.value)?;
129                column += chunk.width;
130            }
131        }
132        write!(self.writer, "{}", clear::UntilNewline)?;
133        self.lines_rendered += 1;
134        Ok(self)
135    }
136
137    /// Finishes rendering. This should be called immediately after the
138    /// [`render`](Self::render) calls are complete.
139    pub fn finish(&mut self) -> io::Result<()> {
140        self.is_dirty = false;
141        if let Some((line, column)) = self.desired_cursor {
142            let up = self.lines_rendered - line - 1;
143            if up != 0 {
144                write!(self.writer, "{}", cursor::Up(up))?;
145            }
146            write!(self.writer, "\r")?;
147            if column != 0 {
148                write!(self.writer, "{}", cursor::Right(column))?;
149            }
150            write!(self.writer, "{}", cursor::Show)?;
151        } else {
152            write!(self.writer, "{}", cursor::Hide)?;
153        }
154        self.writer.flush()
155    }
156
157    /// Leaves the currently-rendered text, making it impossible to clear.
158    ///
159    /// This method may be used if you want to dispose of this `Renderer`
160    /// without clearing the currently-rendered text. This should be called
161    /// after [`finish`](Self::finish).
162    pub fn leave(&mut self) -> io::Result<()> {
163        assert!(!self.is_dirty, "finalize() must be called after rendering");
164        if self.lines_rendered == 0 {
165            return Ok(());
166        }
167        let down = match self.desired_cursor {
168            Some((row, _)) => self.lines_rendered - row - 1,
169            None => 0,
170        };
171        if down != 0 {
172            write!(self.writer, "{}", cursor::Down(down))?;
173        }
174        write!(self.writer, "\n\r")?;
175        self.reset_state();
176        Ok(())
177    }
178}
179
180impl<W: Write> Drop for Renderer<W> {
181    fn drop(&mut self) {
182        let _ = self.clear();
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use crate::element::{Cursor, IntoElement};
189
190    use super::*;
191
192    #[test]
193    fn empty() -> io::Result<()> {
194        let mut r = Renderer::new(vec![]);
195        for _ in 0..3 {
196            r.writer.clear();
197            r.reset()?.finish()?;
198            assert_eq!(r.writer, b"\r\x1b[?25l");
199        }
200        Ok(())
201    }
202
203    #[test]
204    fn empty_line() -> io::Result<()> {
205        let mut r = Renderer::new(vec![]);
206        for _ in 0..3 {
207            r.writer.clear();
208            r.reset()?.render(())?.finish()?;
209            assert_eq!(r.writer, b"\r\x1b[K\x1b[?25l");
210        }
211        Ok(())
212    }
213
214    #[test]
215    fn one_line() -> io::Result<()> {
216        let mut r = Renderer::new(vec![]);
217        for _ in 0..3 {
218            r.writer.clear();
219            r.reset()?.render("trans rights".into_element())?.finish()?;
220            assert_eq!(r.writer, b"\rtrans rights\x1b[m\x1b[K\x1b[?25l");
221        }
222        Ok(())
223    }
224
225    #[test]
226    fn two_lines() -> io::Result<()> {
227        let mut r = Renderer::new(vec![]);
228        r.reset()?
229            .render("trans rights".into_element())?
230            .render("enby rights".into_element())?
231            .finish()?;
232        assert_eq!(
233            r.writer,
234            b"\rtrans rights\x1b[m\x1b[K\n\renby rights\x1b[m\x1b[K\x1b[?25l",
235        );
236
237        for _ in 0..3 {
238            r.writer.clear();
239            r.reset()?
240                .render("trans rights".into_element())?
241                .render("enby rights".into_element())?
242                .finish()?;
243            assert_eq!(
244                r.writer,
245                b"\x1b[1A\rtrans rights\x1b[m\x1b[K\n\renby rights\x1b[m\x1b[K\x1b[?25l",
246            );
247        }
248        Ok(())
249    }
250
251    #[test]
252    fn drop() {
253        let mut out = vec![];
254        Renderer::new(&mut out);
255        assert_eq!(out, b"\r\x1b[J\x1b[?25h");
256    }
257
258    #[test]
259    fn cursor_at_start_of_last_line() -> io::Result<()> {
260        let mut r = Renderer::new(vec![]);
261        r.reset()?
262            .render("trans rights".into_element())?
263            .render((Cursor, "enby rights".into_element()))?
264            .finish()?;
265        assert_eq!(
266            r.writer,
267            b"\rtrans rights\x1b[m\x1b[K\n\renby rights\x1b[m\x1b[K\r\x1b[?25h",
268        );
269        Ok(())
270    }
271
272    #[test]
273    fn cursor_in_last_line() -> io::Result<()> {
274        let mut r = Renderer::new(vec![]);
275        r.reset()?
276            .render("trans rights".into_element())?
277            .render(("enby ".into_element(), Cursor, "rights".into_element()))?
278            .finish()?;
279        assert_eq!(
280            r.writer,
281            b"\rtrans rights\x1b[m\x1b[K\n\renby \x1b[mrights\x1b[m\x1b[K\r\x1b[5C\x1b[?25h",
282        );
283        Ok(())
284    }
285
286    #[test]
287    fn cursor_in_previous_line() -> io::Result<()> {
288        let mut r = Renderer::new(vec![]);
289        r.reset()?
290            .render(("trans rights".into_element(), Cursor))?
291            .render("enby rights".into_element())?
292            .finish()?;
293        assert_eq!(
294            r.writer,
295            b"\rtrans rights\x1b[m\x1b[K\n\renby rights\x1b[m\x1b[K\x1b[1A\r\x1b[12C\x1b[?25h",
296        );
297        Ok(())
298    }
299
300    #[test]
301    fn leave_empty() -> io::Result<()> {
302        let mut r = Renderer::new(vec![]);
303        r.reset()?.finish()?;
304        r.writer.clear();
305        r.leave()?;
306        assert_eq!(r.writer, b"");
307        Ok(())
308    }
309
310    #[test]
311    fn leave() -> io::Result<()> {
312        let mut r = Renderer::new(vec![]);
313        r.reset()?
314            .render("trans rights".into_element())?
315            .render("enby rights".into_element())?
316            .finish()?;
317        r.writer.clear();
318        r.leave()?;
319        r.clear()?;
320        assert_eq!(r.writer, b"\n\r\r\x1b[J\x1b[?25h");
321        Ok(())
322    }
323
324    #[test]
325    fn leave_with_cursor() -> io::Result<()> {
326        let mut r = Renderer::new(vec![]);
327        r.reset()?
328            .render(("trans rights".into_element(), Cursor))?
329            .render("enby rights".into_element())?
330            .finish()?;
331        r.writer.clear();
332        r.leave()?;
333        r.clear()?;
334        assert_eq!(r.writer, b"\x1b[1B\n\r\r\x1b[J\x1b[?25h");
335        Ok(())
336    }
337}