glint/
term_buffer.rs

1use crossterm::{
2    self as ct,
3    cursor::{MoveDown, MoveLeft, MoveRight, MoveUp},
4    style::Print,
5    terminal::{Clear, ClearType},
6};
7use std::cmp::Ordering;
8use std::io::{self, Write as _W};
9
10// If the number of changed lines is larger than this, then
11// we do a full paint.
12const MAX_PATCH_LINES: usize = 3;
13
14/// Represents a range of lines in a terminal and the cursor position. This is
15/// suitable when you don't want to use an "alternate screen", but rather retain
16/// previous terminal output, such as shell prompts/responses.
17///
18/// New frames are rendered by replacing the lines. All operations work on a relative
19/// coordinate system where (0, 0) is the top-left corner of the lines TermBuffer controls.
20///
21/// Further, we never check the actual cursor position, but rather move the cursor relative
22/// to its current position. The meaning of (0, 0) is actually the cursor position when TermBuffer
23/// first renders.
24pub struct TermBuffer {
25    state: State,
26    flushed: State,
27
28    // Cache some structs
29    // terminal: ct::Terminal,
30    stdout: io::Stderr,
31}
32
33impl Drop for TermBuffer {
34    fn drop(&mut self) {
35        if !std::thread::panicking() {
36            self.state = Default::default();
37            self.render_frame();
38        }
39        self.cursor_to_end();
40
41        ct::queue!(self.stdout, Print("".to_string())).unwrap();
42        self.flush();
43    }
44}
45
46impl Default for TermBuffer {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl TermBuffer {
53    pub fn new() -> Self {
54        TermBuffer {
55            state: Default::default(),
56            flushed: Default::default(),
57
58            // Cache some structs
59            // terminal: ct::terminal(),
60            stdout: io::stderr(),
61        }
62    }
63
64    /// Add a row to the desired output
65    pub fn push_line(&mut self, row: impl Into<String>) {
66        self.state.push(row);
67    }
68
69    pub fn lines(&self) -> u16 {
70        self.state.len() as u16
71    }
72
73    /// Positions the cursor where (0, 0) is the first character printed by this program
74    pub fn set_next_cursor(&mut self, cursor: (u16, u16)) {
75        self.state.set_cursor(cursor);
76    }
77
78    /// This causes us to skip past the currently displayed buffer area and forget about it,
79    /// resulting in future renders to happen below it.
80    /// If this is called, and then the TermBuffer is dropped, the default behavior of clearing
81    /// the area will be a no-op.
82    pub fn forget(&mut self) -> usize {
83        let lines = self.flushed.len();
84
85        self.cursor_to_end();
86        self.state = Default::default();
87        self.flushed = Default::default();
88
89        lines
90    }
91
92    /// Perform the necessary update to the terminal. This may choose a more
93    /// optimized update than a full frame.
94    pub fn render_frame(&mut self) {
95        let same_line_count = self.state.len() == self.flushed.len();
96
97        if !same_line_count {
98            return self.render_full();
99        }
100
101        let changed_lines: Vec<_> = self
102            .state
103            .iter()
104            .zip(self.flushed.iter())
105            .enumerate()
106            .filter_map(|(i, (a, b))| if a == b { None } else { Some(i) })
107            .collect();
108
109        let changed_cursor = self.state.cursor != self.flushed.cursor;
110
111        if changed_lines.is_empty() && !changed_cursor {
112            self.flushed = self.state.reset();
113        } else if changed_lines.is_empty() && changed_cursor {
114            match self.state.cursor.1 == self.flushed.cursor.1 {
115                true => self.render_one_line(self.state.cursor.1 as usize),
116                false => {
117                    self.render_one_line(self.flushed.cursor.1 as usize);
118                    self.render_one_line(self.state.cursor.1 as usize);
119                }
120            }
121            self.flushed = self.state.reset();
122        } else if !changed_lines.is_empty() && changed_lines.len() <= MAX_PATCH_LINES {
123            for line_num in changed_lines {
124                self.render_one_line(line_num);
125            }
126            self.flushed = self.state.reset();
127        } else {
128            self.render_full();
129        }
130    }
131
132    fn queue_move_cursor_y(&mut self, down: isize) {
133        match down.cmp(&0) {
134            Ordering::Greater => {
135                let down = down as u16;
136                ct::queue!(self.stdout, MoveDown(down), MoveLeft(1000)).unwrap();
137            }
138            Ordering::Less => {
139                let up = (-down) as u16;
140                ct::queue!(self.stdout, MoveUp(up), MoveLeft(1000)).unwrap();
141            }
142            _ => ct::queue!(self.stdout, MoveLeft(1000)).unwrap(),
143        }
144    }
145
146    pub fn render_one_line(&mut self, line_index: usize) {
147        let down = line_index as isize - self.flushed.cursor.1 as isize;
148
149        let state = self.state.clone();
150
151        self.queue_move_cursor_y(down);
152        let new_y = (self.flushed.cursor.1 as isize + down) as u16;
153
154        let (dx, dy) = state.cursor;
155
156        ct::queue!(self.stdout, Clear(ClearType::UntilNewLine)).unwrap();
157
158        ct::queue!(self.stdout, Print(state.rows[line_index].to_string())).unwrap();
159
160        // This can be enabled to track which lines are updated
161        // ct::queue!(self.stdout, Print(" ΓΈ".to_string())).unwrap();
162
163        ct::queue!(self.stdout, MoveLeft(1000)).unwrap();
164
165        self.queue_move_cursor_y(dy as isize - new_y as isize);
166        if dx > 0 {
167            ct::queue!(self.stdout, MoveRight(dx)).unwrap();
168        }
169
170        self.flushed.cursor = (dx, dy);
171    }
172
173    /// Renders a complete frame to the terminal
174    pub fn render_full(&mut self) {
175        self.cursor_to_start();
176        self.queue_clear();
177
178        let state = self.state.reset();
179
180        for item in state.rows.iter() {
181            ct::queue!(
182                self.stdout,
183                Print(item.to_string()),
184                Print("\n".to_string()),
185                MoveLeft(1000)
186            )
187            .unwrap();
188        }
189
190        let (cx, cy) = (0, state.len() as u16);
191        let (dx, dy) = state.get_cursor();
192        match dy.cmp(&cy) {
193            Ordering::Less => ct::queue!(self.stdout, MoveUp(cy - dy)).unwrap(),
194            Ordering::Greater => ct::queue!(self.stdout, MoveDown(dy - cy)).unwrap(),
195            _ => {}
196        }
197        match dx.cmp(&cx) {
198            Ordering::Less => ct::queue!(self.stdout, MoveLeft(cx - dx)).unwrap(),
199            Ordering::Greater => ct::queue!(self.stdout, MoveRight(dx - cx)).unwrap(),
200            _ => {}
201        }
202
203        ct::queue!(self.stdout, crate::color::reset_item()).unwrap();
204
205        self.flushed = state;
206    }
207
208    pub fn flush(&mut self) {
209        self.stdout.flush().expect("flush failed");
210    }
211
212    fn cursor_to_end(&mut self) {
213        let (cursor_x, cursor_y) = self.flushed.get_cursor();
214        let height = self.flushed.len() as u16;
215        let down = height.saturating_sub(cursor_y);
216
217        let move_down = down > 0;
218        let move_left = cursor_x > 0;
219        if move_down {
220            ct::queue!(self.stdout, MoveDown(down)).unwrap();
221        }
222        if move_left {
223            ct::queue!(self.stdout, MoveLeft(cursor_x)).unwrap();
224        }
225
226        if move_down || move_left {
227            self.flush();
228        }
229    }
230
231    /// Clears from the cursor position down
232    fn queue_clear(&mut self) {
233        ct::queue!(self.stdout, Clear(ClearType::FromCursorDown)).unwrap();
234    }
235
236    fn cursor_to_start(&mut self) {
237        let (_, y) = self.flushed.cursor;
238
239        // if x > 0 {
240        ct::queue!(self.stdout, MoveLeft(1000)).unwrap();
241        // }
242        if y > 0 {
243            ct::queue!(self.stdout, MoveUp(y)).unwrap();
244        }
245    }
246}
247
248/// Represents internal state of TermBuffer
249#[derive(Clone, Debug)]
250struct State {
251    cursor: (u16, u16),
252    rows: Vec<String>,
253    first_row: u16,
254}
255
256impl PartialEq for State {
257    fn eq(&self, other: &Self) -> bool {
258        self.cursor == other.cursor && self.rows == other.rows
259    }
260}
261
262impl Default for State {
263    fn default() -> Self {
264        State {
265            cursor: (0, 0),
266            rows: vec![],
267            first_row: 0,
268        }
269    }
270}
271
272impl State {
273    pub fn len(&self) -> usize {
274        self.rows.len()
275    }
276
277    pub fn push(&mut self, row: impl Into<String>) {
278        self.rows.push(row.into());
279    }
280
281    pub fn set_cursor(&mut self, cursor: (u16, u16)) {
282        self.cursor = cursor;
283    }
284
285    pub fn get_cursor(&self) -> (u16, u16) {
286        self.cursor
287    }
288
289    pub fn reset(&mut self) -> Self {
290        std::mem::take(self)
291    }
292
293    pub fn iter(&self) -> impl Iterator<Item = &str> {
294        self.rows.iter().map(|s| s.as_str())
295    }
296}