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};
9
10use crate::Style;
11use crate::element::Element;
12
13/// A chunk of text with a constant style to be rendered.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RenderChunk<'s> {
16    /// The content of this chunk.
17    pub(crate) value: &'s str,
18    /// The width of this chunk.
19    pub(crate) width: usize,
20    /// The style of this chunk.
21    pub(crate) style: Style,
22    /// Whether to display the cursor at the start of this chunk. If this is
23    /// true, then `value` must be `""`, `width` must be `0`, and `style` must
24    /// be `Style::EMPTY`.
25    pub(crate) cursor: bool,
26}
27
28impl<'s> RenderChunk<'s> {
29    pub const CURSOR: RenderChunk<'static> = RenderChunk {
30        value: "",
31        width: 0,
32        style: Style::EMPTY,
33        cursor: true,
34    };
35
36    pub fn new(value: &'s str, style: Style) -> Self {
37        RenderChunk::with_known_width(value, crate::width(value), style)
38    }
39
40    pub(crate) fn with_known_width(value: &'s str, width: usize, style: Style) -> Self {
41        debug_assert_eq!(crate::width(value), width);
42        RenderChunk {
43            value,
44            width,
45            style,
46            cursor: false,
47        }
48    }
49}
50
51impl<'s> From<&'s str> for RenderChunk<'s> {
52    fn from(value: &'s str) -> Self {
53        RenderChunk::new(value, Style::EMPTY)
54    }
55}
56
57/// A struct that outputs lines to a [writer](Write).
58pub struct Renderer<W: Write> {
59    pub(crate) writer: W,
60    lines_rendered: u16,
61    desired_cursor: Option<(u16, u16)>,
62    is_dirty: bool, // flag for debugging
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            is_dirty: false,
73        }
74    }
75
76    /// Resets the renderer's state.
77    fn reset_state(&mut self) {
78        self.lines_rendered = 0;
79        self.desired_cursor = None;
80        self.is_dirty = false;
81    }
82
83    /// Resets the cursor position, allowing rendering to start over.
84    pub fn reset(&mut self) -> io::Result<&mut Self> {
85        assert!(!self.is_dirty, "finish() must be called after rendering");
86        // Reset the cursor to the top-left.
87        let current_cursor_line = match self.desired_cursor {
88            // If there's a desired cursor position, the cursor is there.
89            Some((line, _)) => line,
90            // Otherwise, it's the last line rendered.
91            None => self.lines_rendered.saturating_sub(1),
92        };
93        if current_cursor_line != 0 {
94            write!(self.writer, "{}", cursor::Up(current_cursor_line))?;
95        }
96        write!(self.writer, "\r")?;
97
98        self.reset_state();
99        Ok(self)
100    }
101
102    /// Clears the UI, resetting the terminal back to its initial state.
103    ///
104    /// Note that this method is automatically called when the `Renderer` is
105    /// [dropped](Drop).
106    pub fn clear(&mut self) -> io::Result<()> {
107        assert!(!self.is_dirty, "finish() must be called after rendering");
108        self.reset()?;
109        write!(self.writer, "{}{}", clear::AfterCursor, cursor::Show)
110    }
111
112    /// Renders a line.
113    pub fn render<'s, E: Element<'s>>(&mut self, line: E) -> io::Result<&mut Self> {
114        self.is_dirty = true;
115        // If this isn't the first line, then move to the next line.
116        if self.lines_rendered != 0 {
117            write!(self.writer, "\n\r")?;
118        }
119        // Render each chunk.
120        let mut column = 0;
121        for chunk in line.render() {
122            if chunk.cursor {
123                debug_assert_eq!(chunk.value, "");
124                debug_assert_eq!(chunk.width, 0);
125                self.desired_cursor = Some((self.lines_rendered, column as u16));
126            } else {
127                write!(self.writer, "{}{}{Reset}", chunk.style, chunk.value)?;
128                column += chunk.width;
129            }
130        }
131        write!(self.writer, "{}", clear::UntilNewline)?;
132        self.lines_rendered += 1;
133        Ok(self)
134    }
135
136    /// Finishes rendering. This should be called immediately after the
137    /// [`render`](Self::render) calls are complete.
138    pub fn finish(&mut self) -> io::Result<()> {
139        self.is_dirty = false;
140        if let Some((line, column)) = self.desired_cursor {
141            let up = self.lines_rendered - line - 1;
142            if up != 0 {
143                write!(self.writer, "{}", cursor::Up(up))?;
144            }
145            write!(self.writer, "\r")?;
146            if column != 0 {
147                write!(self.writer, "{}", cursor::Right(column))?;
148            }
149            write!(self.writer, "{}", cursor::Show)?;
150        } else {
151            write!(self.writer, "{}", cursor::Hide)?;
152        }
153        self.writer.flush()
154    }
155
156    /// Leaves the currently-rendered text, making it impossible to clear.
157    ///
158    /// This method may be used if you want to dispose of this `Renderer`
159    /// without clearing the currently-rendered text. This should be called
160    /// after [`finish`](Self::finish).
161    pub fn leave(&mut self) -> io::Result<()> {
162        assert!(!self.is_dirty, "finish() must be called after rendering");
163        if self.lines_rendered == 0 {
164            return Ok(());
165        }
166        let down = match self.desired_cursor {
167            Some((row, _)) => self.lines_rendered - row - 1,
168            None => 0,
169        };
170        if down != 0 {
171            write!(self.writer, "{}", cursor::Down(down))?;
172        }
173        write!(self.writer, "\n\r")?;
174        self.reset_state();
175        Ok(())
176    }
177}
178
179impl<W: Write> Drop for Renderer<W> {
180    fn drop(&mut self) {
181        if !std::thread::panicking() {
182            // If dropping due to panic, don't bother cleaning up
183            let _ = self.clear();
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use std::panic::AssertUnwindSafe;
191
192    use crate::element::{Cursor, IntoElement};
193
194    use super::*;
195
196    #[test]
197    fn empty() -> io::Result<()> {
198        let mut r = Renderer::new(vec![]);
199        for _ in 0..3 {
200            r.writer.clear();
201            r.reset()?.finish()?;
202            assert_eq!(r.writer, b"\r\x1b[?25l");
203        }
204        Ok(())
205    }
206
207    #[test]
208    fn empty_line() -> io::Result<()> {
209        let mut r = Renderer::new(vec![]);
210        for _ in 0..3 {
211            r.writer.clear();
212            r.reset()?.render(())?.finish()?;
213            assert_eq!(r.writer, b"\r\x1b[K\x1b[?25l");
214        }
215        Ok(())
216    }
217
218    #[test]
219    fn one_line() -> io::Result<()> {
220        let mut r = Renderer::new(vec![]);
221        for _ in 0..3 {
222            r.writer.clear();
223            r.reset()?.render("trans rights".into_element())?.finish()?;
224            assert_eq!(r.writer, b"\rtrans rights\x1b[m\x1b[K\x1b[?25l");
225        }
226        Ok(())
227    }
228
229    #[test]
230    fn two_lines() -> io::Result<()> {
231        let mut r = Renderer::new(vec![]);
232        r.reset()?
233            .render("trans rights".into_element())?
234            .render("enby rights".into_element())?
235            .finish()?;
236        assert_eq!(
237            r.writer,
238            b"\rtrans rights\x1b[m\x1b[K\n\renby rights\x1b[m\x1b[K\x1b[?25l",
239        );
240
241        for _ in 0..3 {
242            r.writer.clear();
243            r.reset()?
244                .render("trans rights".into_element())?
245                .render("enby rights".into_element())?
246                .finish()?;
247            assert_eq!(
248                r.writer,
249                b"\x1b[1A\rtrans rights\x1b[m\x1b[K\n\renby rights\x1b[m\x1b[K\x1b[?25l",
250            );
251        }
252        Ok(())
253    }
254
255    #[test]
256    fn drop() {
257        let mut out = vec![];
258        Renderer::new(&mut out);
259        assert_eq!(out, b"\r\x1b[J\x1b[?25h");
260    }
261
262    #[test]
263    fn cursor_at_start_of_last_line() -> io::Result<()> {
264        let mut r = Renderer::new(vec![]);
265        r.reset()?
266            .render("trans rights".into_element())?
267            .render((Cursor, "enby rights".into_element()))?
268            .finish()?;
269        assert_eq!(
270            r.writer,
271            b"\rtrans rights\x1b[m\x1b[K\n\renby rights\x1b[m\x1b[K\r\x1b[?25h",
272        );
273        Ok(())
274    }
275
276    #[test]
277    fn cursor_in_last_line() -> io::Result<()> {
278        let mut r = Renderer::new(vec![]);
279        r.reset()?
280            .render("trans rights".into_element())?
281            .render(("enby ".into_element(), Cursor, "rights".into_element()))?
282            .finish()?;
283        assert_eq!(
284            r.writer,
285            b"\rtrans rights\x1b[m\x1b[K\n\renby \x1b[mrights\x1b[m\x1b[K\r\x1b[5C\x1b[?25h",
286        );
287        Ok(())
288    }
289
290    #[test]
291    fn cursor_in_previous_line() -> io::Result<()> {
292        let mut r = Renderer::new(vec![]);
293        r.reset()?
294            .render(("trans rights".into_element(), Cursor))?
295            .render("enby rights".into_element())?
296            .finish()?;
297        assert_eq!(
298            r.writer,
299            b"\rtrans rights\x1b[m\x1b[K\n\renby rights\x1b[m\x1b[K\x1b[1A\r\x1b[12C\x1b[?25h",
300        );
301        Ok(())
302    }
303
304    #[test]
305    fn leave_empty() -> io::Result<()> {
306        let mut r = Renderer::new(vec![]);
307        r.reset()?.finish()?;
308        r.writer.clear();
309        r.leave()?;
310        assert_eq!(r.writer, b"");
311        Ok(())
312    }
313
314    #[test]
315    fn leave() -> io::Result<()> {
316        let mut r = Renderer::new(vec![]);
317        r.reset()?
318            .render("trans rights".into_element())?
319            .render("enby rights".into_element())?
320            .finish()?;
321        r.writer.clear();
322        r.leave()?;
323        r.clear()?;
324        assert_eq!(r.writer, b"\n\r\r\x1b[J\x1b[?25h");
325        Ok(())
326    }
327
328    #[test]
329    fn leave_with_cursor() -> io::Result<()> {
330        let mut r = Renderer::new(vec![]);
331        r.reset()?
332            .render(("trans rights".into_element(), Cursor))?
333            .render("enby rights".into_element())?
334            .finish()?;
335        r.writer.clear();
336        r.leave()?;
337        r.clear()?;
338        assert_eq!(r.writer, b"\x1b[1B\n\r\r\x1b[J\x1b[?25h");
339        Ok(())
340    }
341
342    #[test]
343    fn no_drop_during_panic() {
344        let mut output = vec![];
345        let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
346            let mut r = Renderer::new(&mut output);
347            let _ = r.render("hello".into_element());
348            panic!();
349        }));
350        result.unwrap_err();
351        assert_eq!(output, b"hello\x1b[m\x1b[K");
352    }
353}