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 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 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 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}