Skip to main content

promkit_core/
terminal.rs

1use std::{
2    borrow::Borrow,
3    io::{self, Write},
4};
5
6use crate::{
7    crossterm::{cursor, style, terminal},
8    grapheme::StyledGraphemes,
9};
10
11pub struct Terminal {
12    /// The current cursor position within the terminal.
13    pub position: (u16, u16),
14    anchor: (u16, u16),
15}
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18struct DrawPlan {
19    row_count: usize,
20    clear_position: (u16, u16),
21    draw_position: (u16, u16),
22    scroll_down: u16,
23    visible_height: usize,
24    resulting_position: (u16, u16),
25}
26
27impl DrawPlan {
28    fn try_new(
29        row_count: usize,
30        terminal_height: u16,
31        anchor: (u16, u16),
32        position: (u16, u16),
33    ) -> anyhow::Result<Self> {
34        if row_count > terminal_height as usize {
35            return Err(anyhow::anyhow!("Insufficient space to display all panes"));
36        }
37
38        let last_terminal_row = terminal_height.saturating_sub(1);
39        let anchor_row = anchor.1.min(last_terminal_row);
40        let position_row = position.1.min(last_terminal_row);
41        let target_row = if row_count == 0 {
42            anchor_row
43        } else {
44            let row_count =
45                u16::try_from(row_count).expect("row count is bounded by terminal height");
46            anchor_row.min(terminal_height.saturating_sub(row_count))
47        };
48        let scroll_down = target_row.saturating_sub(position_row);
49        let draw_row = position_row.saturating_add(scroll_down);
50
51        Ok(Self {
52            row_count,
53            clear_position: (position.0, draw_row),
54            draw_position: (anchor.0, draw_row),
55            scroll_down,
56            visible_height: terminal_height.saturating_sub(draw_row) as usize,
57            resulting_position: (anchor.0, target_row),
58        })
59    }
60
61    fn scroll_up_after(self, row_index: usize) -> bool {
62        let completed_rows = row_index.saturating_add(1);
63        completed_rows < self.row_count && completed_rows >= self.visible_height.max(1)
64    }
65}
66
67impl Terminal {
68    pub fn new(position: (u16, u16)) -> Self {
69        Self {
70            position,
71            anchor: position,
72        }
73    }
74
75    /// Draws content that still needs terminal-width wrapping.
76    pub fn draw(&mut self, graphemes: &[StyledGraphemes]) -> anyhow::Result<()> {
77        let (width, height) = terminal::size()?;
78        let viewable_rows = graphemes
79            .iter()
80            .map(|graphemes| graphemes.wrapped_lines(width as usize))
81            .filter(|rows| !rows.is_empty())
82            .collect::<Vec<Vec<StyledGraphemes>>>();
83
84        if height < viewable_rows.len() as u16 {
85            return Err(anyhow::anyhow!("Insufficient space to display all panes"));
86        }
87
88        let mut used = 0;
89        let mut panes = Vec::with_capacity(viewable_rows.len());
90        for (pane_index, rows) in viewable_rows.iter().enumerate() {
91            let max_rows = 1
92                .max((height as usize).saturating_sub(used + viewable_rows.len() - 1 - pane_index));
93            let rows = rows.iter().take(max_rows).cloned().collect::<Vec<_>>();
94            used += rows.len();
95            panes.push(rows);
96        }
97
98        self.draw_rows(&panes)
99    }
100
101    /// Draws panes that have already been wrapped and clipped by the renderer.
102    pub fn draw_rows<R>(&mut self, panes: &[Vec<R>]) -> anyhow::Result<()>
103    where
104        R: Borrow<StyledGraphemes>,
105    {
106        let (_, height) = terminal::size()?;
107        self.draw_rows_at_height(panes, height)
108    }
109
110    pub(crate) fn draw_rows_at_height<R>(
111        &mut self,
112        panes: &[Vec<R>],
113        height: u16,
114    ) -> anyhow::Result<()>
115    where
116        R: Borrow<StyledGraphemes>,
117    {
118        let mut stdout = io::stdout();
119        self.draw_rows_to(&mut stdout, panes, height)
120    }
121
122    fn draw_rows_to<W, R>(
123        &mut self,
124        writer: &mut W,
125        panes: &[Vec<R>],
126        height: u16,
127    ) -> anyhow::Result<()>
128    where
129        W: Write,
130        R: Borrow<StyledGraphemes>,
131    {
132        let row_count = panes.iter().map(Vec::len).sum::<usize>();
133        let plan = DrawPlan::try_new(row_count, height, self.anchor, self.position)?;
134
135        crossterm::queue!(
136            writer,
137            terminal::BeginSynchronizedUpdate,
138            terminal::DisableLineWrap,
139        )?;
140        if plan.scroll_down > 0 {
141            crossterm::queue!(writer, terminal::ScrollDown(plan.scroll_down))?;
142        }
143        crossterm::queue!(
144            writer,
145            cursor::MoveTo(plan.clear_position.0, plan.clear_position.1),
146            terminal::Clear(terminal::ClearType::FromCursorDown),
147            cursor::MoveTo(plan.draw_position.0, plan.draw_position.1),
148        )?;
149
150        for (row_index, row) in panes.iter().flatten().enumerate() {
151            crossterm::queue!(writer, style::Print(row.borrow().styled_display()))?;
152
153            if plan.scroll_up_after(row_index) {
154                crossterm::queue!(writer, terminal::ScrollUp(1))?;
155            }
156
157            crossterm::queue!(writer, cursor::MoveToNextLine(1))?;
158        }
159        crossterm::queue!(
160            writer,
161            terminal::EnableLineWrap,
162            terminal::EndSynchronizedUpdate
163        )?;
164        writer.flush()?;
165        self.position = plan.resulting_position;
166        Ok(())
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    mod terminal {
175        use super::*;
176
177        mod draw_rows_to {
178            use super::*;
179            use crate::crossterm::terminal as crossterm_terminal;
180
181            fn rows(count: usize) -> Vec<Vec<StyledGraphemes>> {
182                vec![
183                    (0..count)
184                        .map(|index| StyledGraphemes::from(format!("row {index}")))
185                        .collect(),
186                ]
187            }
188
189            fn command_bytes(command: impl crate::crossterm::Command) -> Vec<u8> {
190                let mut output = Vec::new();
191                crossterm::queue!(output, command).unwrap();
192                output
193            }
194
195            fn command_offset(output: &[u8], command: impl crate::crossterm::Command) -> usize {
196                let command = command_bytes(command);
197                output
198                    .windows(command.len())
199                    .position(|window| window == command)
200                    .expect("expected terminal command was not emitted")
201            }
202
203            #[test]
204            fn growing_frame_scrolls_only_after_clearing_the_previous_frame() {
205                let mut terminal = Terminal::new((0, 7));
206                let mut output = Vec::new();
207
208                terminal.draw_rows_to(&mut output, &rows(8), 10).unwrap();
209
210                let clear = command_offset(
211                    &output,
212                    crossterm_terminal::Clear(crossterm_terminal::ClearType::FromCursorDown),
213                );
214                let scroll = command_offset(&output, crossterm_terminal::ScrollUp(1));
215
216                assert!(clear < scroll);
217                assert_eq!(terminal.position, (0, 2));
218            }
219
220            #[test]
221            fn shrinking_frame_scrolls_preceding_output_back_down_before_redrawing() {
222                let mut terminal = Terminal::new((0, 7));
223                terminal
224                    .draw_rows_to(&mut Vec::new(), &rows(8), 10)
225                    .unwrap();
226                assert_eq!(terminal.position, (0, 2));
227
228                let mut output = Vec::new();
229                terminal.draw_rows_to(&mut output, &rows(3), 10).unwrap();
230
231                let scroll_down = command_offset(&output, crossterm_terminal::ScrollDown(5));
232                let clear = command_offset(
233                    &output,
234                    crossterm_terminal::Clear(crossterm_terminal::ClearType::FromCursorDown),
235                );
236                let draw = command_bytes(cursor::MoveTo(0, 7));
237                let draw = output
238                    .windows(draw.len())
239                    .enumerate()
240                    .filter(|(_, window)| *window == draw)
241                    .map(|(offset, _)| offset)
242                    .nth(1)
243                    .expect("expected a move from the clear position to the draw position");
244
245                assert!(scroll_down < clear);
246                assert!(clear < draw);
247                assert_eq!(terminal.position, (0, 7));
248            }
249
250            #[test]
251            fn draw_frame_controls_wrapping_inside_a_synchronized_update() {
252                let mut terminal = Terminal::new((0, 0));
253                let mut output = Vec::new();
254
255                terminal.draw_rows_to(&mut output, &rows(1), 10).unwrap();
256
257                let begin = command_offset(&output, crossterm_terminal::BeginSynchronizedUpdate);
258                let disable_wrap = command_offset(&output, crossterm_terminal::DisableLineWrap);
259                let enable_wrap = command_offset(&output, crossterm_terminal::EnableLineWrap);
260                let end = command_offset(&output, crossterm_terminal::EndSynchronizedUpdate);
261
262                assert!(begin < disable_wrap);
263                assert!(disable_wrap < enable_wrap);
264                assert!(enable_wrap < end);
265            }
266
267            #[test]
268            fn trailing_empty_panes_do_not_trigger_scrolling() {
269                let mut terminal = Terminal::new((0, 9));
270                let panes: Vec<Vec<StyledGraphemes>> =
271                    vec![vec![StyledGraphemes::from("only row")], Vec::new()];
272
273                terminal.draw_rows_to(&mut Vec::new(), &panes, 10).unwrap();
274
275                assert_eq!(terminal.position, (0, 9));
276            }
277        }
278    }
279}