sericom_core/
screen_buffer.rs

1//! This module contains the code needed for the implementation of a
2//! stateful buffer that holds a history of the lines/data received
3//! from the serial connection and the rendering/updating of the buffer
4//! to the terminal screen (stdout).
5//!
6//! Simply writing the data received from the serial connection directly
7//! to stdout creates one main issue: there is no history of previous lines
8//! that were received from the serial connection. Without a screen buffer,
9//! lines would simply be wiped from existence as they exit the terminal's screen.
10//!
11//! As a result, there would be no way to implement features like scrolling,
12//! highlighting text (for UI purposes), and getting characters at specific
13//! locations within the screen for things like copying to a clipboard.
14//!
15//! The screen buffer solves these issues by storing each line received from the
16//! connection in a [`VecDeque`]. It is important to note that
17//! currently, the **capacity of the `VecDeque` is not hardcoded and is theoretically
18//! allowed to grow forever**, limited by memory.
19
20use crate::configs::get_config;
21use crossterm::style::Color;
22use std::{collections::VecDeque, io::BufWriter};
23
24const MIN_RENDER_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_millis(33);
25
26/// `EscapeState` holds stateful information about the incoming
27/// data to allow for proper processing of ansii escape codes/characters.
28enum EscapeState {
29    /// Has not received ansii escape characters
30    Normal,
31    /// Just received an ESC (0x1B)
32    Esc,
33    /// Received ESC and then '[' (0x5B)
34    Csi,
35}
36
37/// `UICommand` is used for communication between stdin and the [`ScreenBuffer`].
38#[non_exhaustive]
39#[derive(Clone, Debug)]
40pub enum UICommand {
41    ScrollUp(usize),
42    ScrollDown(usize),
43    ScrollBottom,
44    ScrollTop,
45    StartSelection(u16, u16),
46    UpdateSelection(u16, u16),
47    CopySelection,
48    ClearBuffer,
49}
50
51/// `Cell` represents a cell within the terminal's window/frame.
52/// Used to hold rendering state for all the cells within the [`ScreenBuffer`].
53/// Each line within `ScreenBuffer` is represented by a `Vec<Cell>`.
54#[derive(Clone, Debug)]
55struct Cell {
56    character: char,
57    fg_color: Color,
58    bg_color: Color,
59    is_selected: bool,
60}
61
62impl Default for Cell {
63    fn default() -> Self {
64        let config = get_config();
65        Self {
66            character: ' ',
67            fg_color: Color::from(&config.appearance.fg),
68            bg_color: Color::from(&config.appearance.bg),
69            is_selected: false,
70        }
71    }
72}
73
74/// Represent's the cursor's position within the [`ScreenBuffer`].
75#[derive(Clone, Copy, Debug)]
76struct Position {
77    /// The x position of a line within `ScreenBuffer`'s scrollback buffer.
78    /// This translates to the `Cell` within a line (`Vec`).
79    x: u16,
80    /// `y` is the line number within `ScreenBuffer`'s scrollback buffer.
81    y: usize,
82}
83
84impl From<(u16, usize)> for Position {
85    fn from((x, y): (u16, usize)) -> Self {
86        Self { x, y }
87    }
88}
89
90impl From<(u16, u16)> for Position {
91    fn from((x, y): (u16, u16)) -> Self {
92        Self { x, y: y as usize }
93    }
94}
95
96impl From<Position> for (u16, usize) {
97    fn from(position: Position) -> Self {
98        (position.x, position.y)
99    }
100}
101
102impl From<Position> for (u16, u16) {
103    fn from(position: Position) -> Self {
104        (position.x, position.y as u16)
105    }
106}
107
108/// The `ScreenBuffer` holds rendering state for the entire terminal's window/frame.
109/// It mainly serves to allow for user-interactions that require a history and location
110/// of the data displayed within the terminal i.e. copy/paste, scrolling, & highlighting.
111pub struct ScreenBuffer {
112    /// Terminal width
113    width: u16,
114    /// Terminal height
115    height: u16,
116    /// Scrollback buffer (all lines received from the serial connection).
117    /// Limited by memory.
118    lines: VecDeque<Vec<Cell>>,
119    /// Current view into the buffer.
120    /// Denotes which line is at the top of the screen.
121    view_start: usize,
122    /// Position of the cursor within the `ScreenBuffer`.
123    cursor_pos: Position,
124    /// Start of text selection. Used for highlighting and copying to clipboard.
125    selection_start: Option<(u16, usize)>,
126    /// End of text selection. Used for highlighting and copying to clipboard.
127    selection_end: Option<(u16, usize)>,
128    /// Configuration for the maximum amount of lines to keep in memory.
129    max_scrollback: usize,
130    /// Represents the current state for handling ansii escape sequences
131    /// as incoming data is being processed.
132    escape_state: EscapeState,
133    last_render: Option<tokio::time::Instant>,
134    needs_render: bool,
135}
136
137impl ScreenBuffer {
138    /// Constructs a new `ScreenBuffer` to be used at the start of a
139    /// serial connection session.
140    pub fn new(width: u16, height: u16, max_scrollback: usize) -> Self {
141        let mut buffer = Self {
142            width,
143            height,
144            lines: VecDeque::new(),
145            view_start: 0,
146            cursor_pos: Position { x: 0, y: 0 },
147            selection_start: None,
148            selection_end: None,
149            max_scrollback,
150            last_render: None,
151            needs_render: false,
152            escape_state: EscapeState::Normal,
153        };
154        // Start with an empty line
155        buffer
156            .lines
157            .push_back(vec![Cell::default(); width as usize]);
158        buffer
159    }
160
161    fn set_cursor_pos<P: Into<Position>>(&mut self, position: P) {
162        self.cursor_pos = position.into();
163    }
164
165    fn move_cursor_left(&mut self) {
166        self.cursor_pos.x = self.cursor_pos.x.saturating_sub(1);
167    }
168
169    fn move_cursor_right(&mut self) {
170        self.cursor_pos.x = self.cursor_pos.x.saturating_add(1);
171    }
172
173    /// Takes incoming data (bytes (`u8`) from a serial connection) and
174    /// processes them accordingly, handling ascii escape sequences, to
175    /// render as characters/strings in the terminal.
176    pub fn add_data(&mut self, data: &[u8]) {
177        let text = String::from_utf8_lossy(data);
178        let mut chars = text.chars().peekable();
179
180        while let Some(ch) = chars.next() {
181            match self.escape_state {
182                EscapeState::Normal => {
183                    match ch {
184                        '\r' => {
185                            self.cursor_pos.x = 0;
186                            if chars.peek() == Some(&'\n') {
187                                chars.next();
188                                self.new_line();
189                            }
190                        }
191                        '\n' => {
192                            self.new_line();
193                        }
194                        '\x07' => {}
195                        '\x08' => {
196                            let mut temp_chars = chars.clone();
197                            // Matches the `\x08 ' ' \x08` deletion sequence
198                            if let (Some(' '), Some('\x08')) =
199                                (temp_chars.next(), temp_chars.next())
200                            {
201                                // Consume them - to remove from further processing
202                                chars.next();
203                                chars.next();
204                                self.move_cursor_left();
205                                self.set_char_at_cursor(' ');
206                            } else {
207                                // If not the deletion sequence, move cursor left
208                                // when receiving a single '\x08'
209                                self.move_cursor_left();
210                            }
211                        }
212                        '\x1B' => {
213                            self.escape_state = EscapeState::Esc;
214                        }
215                        c => {
216                            let mut batch = vec![c];
217                            while let Some(&next_ch) = chars.peek() {
218                                if next_ch.is_control()
219                                    || next_ch == '\x1B'
220                                    || self.cursor_pos.x + batch.len() as u16 >= self.width
221                                {
222                                    break;
223                                }
224                                batch.push(chars.next().unwrap());
225                            }
226                            self.add_char_batch(&batch);
227                        }
228                    }
229                }
230                EscapeState::Esc => match ch {
231                    '[' => {
232                        self.escape_state = EscapeState::Csi;
233                    }
234                    _ => {
235                        self.escape_state = EscapeState::Normal;
236                    }
237                },
238                EscapeState::Csi => match ch {
239                    'J' => {
240                        self.clear_from_cursor_to_eol();
241                        self.escape_state = EscapeState::Normal;
242                    }
243                    'K' => {
244                        self.clear_from_cursor_to_eol();
245                        self.escape_state = EscapeState::Normal;
246                    }
247                    'C' => {
248                        self.move_cursor_left();
249                        self.escape_state = EscapeState::Normal;
250                    }
251                    'D' => {
252                        self.move_cursor_right();
253                        self.escape_state = EscapeState::Normal;
254                    }
255                    _ => {
256                        self.escape_state = EscapeState::Normal;
257                    }
258                },
259            }
260        }
261        self.scroll_to_bottom();
262        self.needs_render = true;
263    }
264
265    fn add_char_batch(&mut self, chars: &[char]) {
266        while self.cursor_pos.y >= self.lines.len() {
267            self.lines
268                .push_back(vec![Cell::default(); self.width as usize]);
269        }
270
271        if let Some(line) = self.lines.get_mut(self.cursor_pos.y) {
272            for &ch in chars {
273                if (self.cursor_pos.x as usize) < line.len() {
274                    line[self.cursor_pos.x as usize].character = ch;
275                    self.cursor_pos.x += 1;
276                    if self.cursor_pos.x >= self.width {
277                        self.new_line();
278                        break;
279                    }
280                }
281            }
282        }
283    }
284
285    /// A helper function to check whether the terminal's screen should be rendered.
286    pub fn should_render_now(&self) -> bool {
287        use tokio::time::Instant;
288
289        if !self.needs_render {
290            return false;
291        }
292
293        let now = Instant::now();
294        match self.last_render {
295            Some(last) => now.duration_since(last) >= MIN_RENDER_INTERVAL,
296            None => true,
297        }
298    }
299
300    fn set_char_at_cursor(&mut self, ch: char) {
301        while self.cursor_pos.y >= self.lines.len() {
302            self.lines
303                .push_back(vec![Cell::default(); self.width as usize]);
304        }
305
306        if let Some(line) = self.lines.get_mut(self.cursor_pos.y)
307            && (self.cursor_pos.x as usize) < line.len()
308        {
309            line[self.cursor_pos.x as usize].character = ch;
310        }
311    }
312
313    #[allow(clippy::needless_range_loop)]
314    fn clear_from_cursor_to_eol(&mut self) {
315        if let Some(line) = self.lines.get_mut(self.cursor_pos.y) {
316            for x in (self.cursor_pos.x as usize)..line.len() {
317                line[x] = Cell::default();
318            }
319        }
320    }
321
322    fn new_line(&mut self) {
323        self.set_cursor_pos((0, self.cursor_pos.y + 1));
324
325        if self.cursor_pos.y >= self.lines.len() {
326            self.lines
327                .push_back(vec![Cell::default(); self.width as usize]);
328        }
329
330        // Remove old lines if exceeding `ScreenBuffer.max_scrollback`
331        while self.lines.len() > self.max_scrollback {
332            self.lines.pop_front();
333            // Update the view position
334            if self.cursor_pos.y > 0 {
335                self.cursor_pos.y -= 1;
336            }
337            if self.view_start > 0 {
338                self.view_start -= 1;
339            }
340        }
341    }
342
343    /// Called to scroll the terminal up by `lines`.
344    pub fn scroll_up(&mut self, lines: usize) {
345        if self.view_start >= lines {
346            self.view_start -= lines;
347        } else {
348            self.view_start = 0;
349        }
350        self.clear_selection();
351        self.needs_render = true;
352    }
353
354    /// Called to scroll the terminal down by `lines`.
355    pub fn scroll_down(&mut self, lines: usize) {
356        let max_view_start = self.lines.len().saturating_sub(self.height as usize);
357        self.view_start = (self.view_start + lines).min(max_view_start);
358        self.clear_selection();
359        self.needs_render = true;
360    }
361
362    /// Scrolls to the bottom of the screen. The bottom of the screen is
363    /// the same as the most recent lines received from the serial connection
364    pub fn scroll_to_bottom(&mut self) {
365        self.view_start = self.lines.len().saturating_sub(self.height as usize);
366        self.needs_render = true;
367    }
368
369    /// Scrolls to the top of the serial connection's history.
370    pub fn scroll_to_top(&mut self) {
371        self.view_start = 0;
372        self.needs_render = true;
373    }
374
375    /// Sets the position within the screen for the start of a selection.
376    /// Where `screen_x` is the x-position of the start of the selection,
377    /// and `screen_y` is the y-position (line) of the start of the selection.
378    pub fn start_selection(&mut self, screen_x: u16, screen_y: u16) {
379        let absolute_line = self.view_start + screen_y as usize;
380        self.clear_selection();
381        self.selection_start = Some((screen_x, absolute_line));
382        self.needs_render = true;
383    }
384
385    /// Update's a selection to include the position passed to it.
386    /// Where `screen_x` is the x-position and `screen_y` is the y-position (line).
387    pub fn update_selection(&mut self, screen_x: u16, screen_y: u16) {
388        let absolute_line = self.view_start + screen_y as usize;
389        self.selection_end = Some((screen_x, absolute_line));
390        self.update_selection_highlighting();
391        self.needs_render = true;
392    }
393
394    /// Clears the selection state.
395    pub fn clear_selection(&mut self) {
396        for line in &mut self.lines {
397            for cell in line {
398                cell.is_selected = false;
399            }
400        }
401        self.selection_start = None;
402        self.selection_end = None;
403        self.needs_render = true;
404    }
405
406    fn update_selection_highlighting(&mut self) {
407        for line in &mut self.lines {
408            for cell in line {
409                cell.is_selected = false;
410            }
411        }
412
413        if let (Some((start_x, start_line)), Some((end_x, end_line))) =
414            (self.selection_start, self.selection_end)
415        {
416            let (start_line, start_x, end_line, end_x) =
417                if start_line < end_line || (start_line == end_line && start_x <= end_x) {
418                    (start_line, start_x, end_line, end_x)
419                } else {
420                    (end_line, end_x, start_line, start_x)
421                };
422
423            for line_idx in start_line..=end_line {
424                if let Some(line) = self.lines.get_mut(line_idx) {
425                    let line_start_x = if line_idx == start_line { start_x } else { 0 };
426                    let line_end_x = if line_idx == end_line {
427                        end_x
428                    } else {
429                        self.width - 1
430                    };
431
432                    for x in line_start_x..=line_end_x.min(self.width - 1) {
433                        if let Some(cell) = line.get_mut(x as usize) {
434                            cell.is_selected = true;
435                        }
436                    }
437                }
438            }
439        }
440    }
441
442    fn get_selected_text(&self) -> String {
443        if let (Some((start_x, start_line)), Some((end_x, end_line))) =
444            (self.selection_start, self.selection_end)
445        {
446            let (start_line, start_x, end_line, end_x) =
447                if start_line < end_line || (start_line == end_line && start_x <= end_x) {
448                    (start_line, start_x, end_line, end_x)
449                } else {
450                    (end_line, end_x, start_line, start_x)
451                };
452
453            let mut result = String::new();
454
455            for line_idx in start_line..=end_line {
456                if let Some(line) = self.lines.get(line_idx) {
457                    let line_start_x = if line_idx == start_line { start_x } else { 0 };
458                    let line_end_x = if line_idx == end_line {
459                        end_x
460                    } else {
461                        self.width - 1
462                    };
463
464                    for x in line_start_x..=line_end_x.min(self.width - 1) {
465                        if let Some(cell) = line.get(x as usize) {
466                            result.push(cell.character);
467                        }
468                    }
469
470                    if line_idx < end_line {
471                        result.push('\n');
472                    }
473                }
474            }
475
476            result.trim_end().to_string()
477        } else {
478            String::new()
479        }
480    }
481
482    /// Copy's the currently selected text to the user's clipboard.
483    pub fn copy_to_clipboard(&mut self) -> std::io::Result<()> {
484        use crossterm::{clipboard, execute};
485
486        let selected_text = self.get_selected_text();
487        if !selected_text.is_empty() {
488            execute!(
489                std::io::stdout(),
490                clipboard::CopyToClipboard::to_clipboard_from(selected_text)
491            )?;
492        }
493        self.clear_selection();
494        Ok(())
495    }
496
497    #[allow(dead_code)]
498    fn get_stats(&self) -> BufferStats {
499        let total_lines = self.lines.len();
500        BufferStats {
501            total_lines,
502            view_start: self.view_start,
503            view_end: (self.view_start + self.height as usize).min(total_lines),
504            cursor_line: self.cursor_pos.y,
505            has_selection: self.selection_start.is_some() && self.selection_end.is_some(),
506        }
507    }
508
509    /// Clears the entire serial connection's history and reset's the screen.
510    /// Similar to <kbd>Ctrl</kbd> + <kbd>l</kbd> in a terminal, except this will reset the
511    /// connection's message history (on the user's side).
512    pub fn clear_buffer(&mut self) {
513        self.lines.clear();
514        self.view_start = 0;
515        self.set_cursor_pos((0_u16, 0_usize));
516        self.lines
517            .push_back(vec![Cell::default(); self.width as usize]);
518        self.needs_render = true;
519    }
520
521    /// Writes the lines/characters received from `add_data` to the terminal's screen.
522    /// As of now, `render` does not involve any diff-ing of previous renders.
523    ///
524    /// The nature of communicating to devices over a serial connection is similar
525    /// that of a terminal; lines get printed to a screen and with each new line,
526    /// all of the previously rendered characters must be re-rendered one cell higher.
527    ///
528    /// Because of this, the only diff-ing that would make sense would be
529    /// that of the cells within the screen that are simply blank.
530    pub fn render(&mut self) -> std::io::Result<()> {
531        use crossterm::{cursor, queue, style};
532        use std::io::{self, Write};
533        use tokio::time::Instant;
534
535        if !self.needs_render {
536            return Ok(());
537        }
538
539        let mut writer = BufWriter::new(io::stdout());
540        queue!(writer, cursor::Hide)?;
541
542        for screen_y in 0..self.height {
543            let line_idx = self.view_start + screen_y as usize;
544            queue!(writer, cursor::MoveTo(0, screen_y))?;
545
546            if let Some(line) = self.lines.get(line_idx) {
547                let config = get_config();
548                let mut current_fg = Color::from(&config.appearance.fg);
549                let mut current_bg = Color::from(&config.appearance.bg);
550                queue!(writer, style::SetForegroundColor(current_fg))?;
551                queue!(writer, style::SetBackgroundColor(current_bg))?;
552
553                for cell in line {
554                    let fg = if cell.is_selected {
555                        Color::from(&config.appearance.hl_fg)
556                    } else {
557                        cell.fg_color
558                    };
559                    let bg = if cell.is_selected {
560                        Color::from(&config.appearance.hl_bg)
561                    } else {
562                        cell.bg_color
563                    };
564                    if fg != current_fg {
565                        queue!(writer, style::SetForegroundColor(fg))?;
566                        current_fg = fg;
567                    }
568                    if bg != current_bg {
569                        queue!(writer, style::SetBackgroundColor(bg))?;
570                        current_bg = bg;
571                    }
572                    queue!(writer, style::Print(cell.character))?;
573                }
574            } else {
575                queue!(writer, style::ResetColor)?;
576                queue!(writer, style::Print(" ".repeat(self.width as usize)))?;
577            }
578        }
579
580        let screen_cursor_y = if self.cursor_pos.y >= self.view_start
581            && self.cursor_pos.y < self.view_start + self.height as usize
582        {
583            (self.cursor_pos.y - self.view_start) as u16
584        } else {
585            self.height - 1
586        };
587
588        queue!(
589            writer,
590            cursor::MoveTo(self.cursor_pos.x, screen_cursor_y),
591            cursor::Show
592        )?;
593        writer.flush()?;
594        self.last_render = Some(Instant::now());
595        self.needs_render = false;
596        Ok(())
597    }
598}
599
600#[allow(dead_code)]
601#[derive(Debug)]
602struct BufferStats {
603    pub total_lines: usize,
604    pub view_start: usize,
605    pub view_end: usize,
606    pub cursor_line: usize,
607    pub has_selection: bool,
608}