From 8ca4e5b3aac996f0b93f04ffe1b26b21bf85a0aa Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Fri, 26 Sep 2025 14:58:40 -0500 Subject: [PATCH 01/40] refactor(render): In the middle of refactoring how rendering works. changelog: ignore (sericom-core) --- justfile | 11 + sericom-core/Cargo.toml | 5 + sericom-core/src/lib.rs | 1 + sericom-core/src/screen_buffer/cursor.rs | 186 +++---- sericom-core/src/screen_buffer/escape.rs | 480 +++++++++--------- sericom-core/src/screen_buffer/mod.rs | 121 +++-- sericom-core/src/screen_buffer/render.rs | 150 ++---- sericom-core/src/screen_buffer/ui_command.rs | 4 +- sericom-core/src/serial_actor/mod.rs | 1 + sericom-core/src/serial_actor/parser.rs | 1 + sericom-core/src/serial_actor/tasks.rs | 140 ++++- sericom-core/src/ui/ascii/mod.rs | 23 + sericom-core/src/ui/ascii/parser.rs | 90 ++++ sericom-core/src/ui/ascii/process.rs | 168 ++++++ sericom-core/src/ui/buffer.rs | 29 ++ sericom-core/src/ui/frame.rs | 37 ++ .../src/{screen_buffer => ui/line}/cell.rs | 35 +- sericom-core/src/ui/line/color_state.rs | 35 ++ .../src/{screen_buffer => ui/line}/line.rs | 76 ++- sericom-core/src/ui/line/mod.rs | 8 + sericom-core/src/ui/line/span.rs | 63 +++ sericom-core/src/ui/mod.rs | 15 + sericom-core/src/ui/position.rs | 82 +++ sericom-core/src/ui/rect.rs | 72 +++ sericom-core/src/ui/terminal.rs | 57 +++ 25 files changed, 1370 insertions(+), 520 deletions(-) create mode 100644 justfile create mode 100644 sericom-core/src/serial_actor/parser.rs create mode 100644 sericom-core/src/ui/ascii/mod.rs create mode 100644 sericom-core/src/ui/ascii/parser.rs create mode 100644 sericom-core/src/ui/ascii/process.rs create mode 100644 sericom-core/src/ui/buffer.rs create mode 100644 sericom-core/src/ui/frame.rs rename sericom-core/src/{screen_buffer => ui/line}/cell.rs (58%) create mode 100644 sericom-core/src/ui/line/color_state.rs rename sericom-core/src/{screen_buffer => ui/line}/line.rs (58%) create mode 100644 sericom-core/src/ui/line/mod.rs create mode 100644 sericom-core/src/ui/line/span.rs create mode 100644 sericom-core/src/ui/mod.rs create mode 100644 sericom-core/src/ui/position.rs create mode 100644 sericom-core/src/ui/rect.rs create mode 100644 sericom-core/src/ui/terminal.rs diff --git a/justfile b/justfile new file mode 100644 index 0000000..9d8b9f2 --- /dev/null +++ b/justfile @@ -0,0 +1,11 @@ +run: + cargo r -- /dev/ttyUSB0 + +run-file: + cargo r -- /dev/ttyUSB0 -f + +run-trace: + cargo r -- /dev/ttyUSB0 -d + +check-win: + cargo c --target x86_64-pc-windows-msvc diff --git a/sericom-core/Cargo.toml b/sericom-core/Cargo.toml index 2937315..24135ac 100644 --- a/sericom-core/Cargo.toml +++ b/sericom-core/Cargo.toml @@ -18,3 +18,8 @@ miette.workspace = true serial2-tokio.workspace = true tokio.workspace = true tracing.workspace = true + +[lints.clippy] +pedantic = "warn" +perf = "warn" +nursery = "warn" diff --git a/sericom-core/src/lib.rs b/sericom-core/src/lib.rs index b0f90fb..efdfcbd 100644 --- a/sericom-core/src/lib.rs +++ b/sericom-core/src/lib.rs @@ -15,3 +15,4 @@ pub mod debug; pub mod path_utils; pub mod screen_buffer; pub mod serial_actor; +pub mod ui; diff --git a/sericom-core/src/screen_buffer/cursor.rs b/sericom-core/src/screen_buffer/cursor.rs index b27d95b..bce49c1 100644 --- a/sericom-core/src/screen_buffer/cursor.rs +++ b/sericom-core/src/screen_buffer/cursor.rs @@ -1,94 +1,96 @@ -use std::fmt::Display; +// use std::fmt::Display; +// +// use super::{Line, ScreenBuffer}; +// +// /// Represent's the cursor's position within the [`ScreenBuffer`]. +// #[derive(Clone, Copy, Debug)] +// pub struct Position { +// /// The column within [`ScreenBuffer`]'s scrollback buffer. +// /// This translates to the [`Cell`][`super::Cell`] within a line (`Vec`). +// pub(crate) x: u16, +// /// The line number within [`ScreenBuffer`]'s scrollback buffer. +// pub(crate) y: usize, +// } +// +// impl Position { +// pub const ORIGIN: Self = Self { x: 0, y: 0 }; +// +// /// Sets [`Position`] to (0,0) +// pub(super) const fn home() -> Self { +// Self { x: 0, y: 0 } +// } +// } +// +// impl From<(u16, usize)> for Position { +// fn from((x, y): (u16, usize)) -> Self { +// Self { x, y } +// } +// } +// +// impl From<(u16, u16)> for Position { +// fn from((x, y): (u16, u16)) -> Self { +// Self { x, y: y as usize } +// } +// } +// +// impl From for (u16, usize) { +// fn from(position: Position) -> Self { +// (position.x, position.y) +// } +// } +// +// impl From for (u16, u16) { +// fn from(position: Position) -> Self { +// (position.x, position.y as u16) +// } +// } +// +// impl Display for Position { +// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +// write!(f, "({}, {})", self.x, self.y) +// } +// } -use super::{Line, ScreenBuffer}; +// pub trait Cursor { +// fn set_cursor_pos>(&mut self, position: P); +// fn move_cursor_left(&mut self, cells: u16); +// fn move_cursor_up(&mut self, lines: u16); +// fn move_cursor_down(&mut self, lines: u16); +// fn move_cursor_right(&mut self, cells: u16); +// fn set_cursor_col(&mut self, col: u16); +// } -/// Represent's the cursor's position within the [`ScreenBuffer`]. -#[derive(Clone, Copy, Debug)] -pub struct Position { - /// The column within [`ScreenBuffer`]'s scrollback buffer. - /// This translates to the [`Cell`][`super::Cell`] within a line (`Vec`). - pub(crate) x: u16, - /// The line number within [`ScreenBuffer`]'s scrollback buffer. - pub(crate) y: usize, -} - -impl Position { - /// Sets [`Position`] to (0,0) - pub(super) const fn home() -> Self { - Self { x: 0, y: 0 } - } -} - -impl From<(u16, usize)> for Position { - fn from((x, y): (u16, usize)) -> Self { - Self { x, y } - } -} - -impl From<(u16, u16)> for Position { - fn from((x, y): (u16, u16)) -> Self { - Self { x, y: y as usize } - } -} - -impl From for (u16, usize) { - fn from(position: Position) -> Self { - (position.x, position.y) - } -} - -impl From for (u16, u16) { - fn from(position: Position) -> Self { - (position.x, position.y as u16) - } -} - -impl Display for Position { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "({}, {})", self.x, self.y) - } -} - -pub trait Cursor { - fn set_cursor_pos>(&mut self, position: P); - fn move_cursor_left(&mut self, cells: u16); - fn move_cursor_up(&mut self, lines: u16); - fn move_cursor_down(&mut self, lines: u16); - fn move_cursor_right(&mut self, cells: u16); - fn set_cursor_col(&mut self, col: u16); -} - -impl Cursor for ScreenBuffer { - /// Sets the cursor position. - fn set_cursor_pos>(&mut self, position: P) { - self.cursor_pos = position.into(); - } - - /// Moves the cursor left by `cells`. - fn move_cursor_left(&mut self, cells: u16) { - self.cursor_pos.x = self.cursor_pos.x.saturating_sub(cells); - } - - /// Moves the cursor up by `lines`. - fn move_cursor_up(&mut self, lines: u16) { - self.cursor_pos.y = self.cursor_pos.y.saturating_sub(lines as usize); - } - - /// Moves the cursor down by `lines`. - fn move_cursor_down(&mut self, lines: u16) { - self.cursor_pos.y = self.cursor_pos.y.saturating_add(lines as usize); - while self.cursor_pos.y > self.lines.len() { - self.lines.push_back(Line::new(self.width as usize)); - } - } - - /// Moves the cursor right by `cells`. - fn move_cursor_right(&mut self, cells: u16) { - self.cursor_pos.x = self.cursor_pos.x.saturating_add(cells); - } - - /// Sets the column of the cursor - fn set_cursor_col(&mut self, col: u16) { - self.cursor_pos.x = col; - } -} +// impl Cursor for ScreenBuffer { +// /// Sets the cursor position. +// fn set_cursor_pos>(&mut self, position: P) { +// self.cursor_pos = position.into(); +// } +// +// /// Moves the cursor left by `cells`. +// fn move_cursor_left(&mut self, cells: u16) { +// self.cursor_pos.x = self.cursor_pos.x.saturating_sub(cells); +// } +// +// /// Moves the cursor up by `lines`. +// fn move_cursor_up(&mut self, lines: u16) { +// self.cursor_pos.y = self.cursor_pos.y.saturating_sub(lines as usize); +// } +// +// /// Moves the cursor down by `lines`. +// fn move_cursor_down(&mut self, lines: u16) { +// self.cursor_pos.y = self.cursor_pos.y.saturating_add(lines as usize); +// while self.cursor_pos.y > self.lines.len() { +// self.lines.push_back(Line::new_default(self.width.into())); +// } +// } +// +// /// Moves the cursor right by `cells`. +// fn move_cursor_right(&mut self, cells: u16) { +// self.cursor_pos.x = self.cursor_pos.x.saturating_add(cells); +// } +// +// /// Sets the column of the cursor +// fn set_cursor_col(&mut self, col: u16) { +// self.cursor_pos.x = col; +// } +// } diff --git a/sericom-core/src/screen_buffer/escape.rs b/sericom-core/src/screen_buffer/escape.rs index 2afe2c5..442e8e0 100644 --- a/sericom-core/src/screen_buffer/escape.rs +++ b/sericom-core/src/screen_buffer/escape.rs @@ -1,240 +1,240 @@ -use crossterm::style::Attributes; -use tracing::debug; - -use super::{Cursor, Line, ScreenBuffer}; -use crate::screen_buffer::UIAction; - -/// `EscapeState` holds stateful information about the incoming -/// data to allow for proper processing of ansii escape codes/characters. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum EscapeState { - /// Has not received ansii escape characters - Normal, - /// Just received an ESC (0x1B) - Esc, - /// Received ESC and then '[' (0x5B) - Csi, -} - -/// Represents a section of an ascii escape sequence. -#[derive(Clone, Default, Debug, PartialEq, Eq)] -pub(super) enum EscapePart { - /// The default state when not actively processing an escape sequence. - #[default] - Empty, - /// Collects ascii digits (0-9) as they are received individually and are - /// eventually combined to create the final number that the `Action` will - /// perform on i.e. `vec!['2', '3']` -> `23`. - Numbers(Vec), - /// The `Separator` represents the `;` used in ascii escape sequences. - Separator, - /// The `Action` represents the (typically) last letter of an escape - /// sequence that determines what action is to be taken i.e. `ESC[2J`. - Action(char), -} - -/// A state-holder/collection for building ascii escape sequences -/// from incoming data to enable proper processing/execution. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct EscapeSequence { - sequence: Vec, - part: EscapePart, -} - -impl EscapeSequence { - pub(super) fn new() -> Self { - Self { - sequence: Vec::new(), - part: EscapePart::Empty, - } - } - - /// Clear's the sequence and sets [`Self::part`] to [`EscapePart::Empty`]. - pub(super) fn reset(&mut self) { - // Clear is probably good since it will continue to - // fill up to similar sizes throughout the program. - self.sequence.clear(); - self.part = EscapePart::Empty; - } - - /// Appends the [`Self::part`] that is currently being processed to [`Self::sequence`]. - fn push_part(&mut self) { - self.sequence.push(std::mem::take(&mut self.part)); - } - - /// Appends a [`EscapePart::Separator`] to [`Self::sequence`]. - pub(super) fn insert_separator(&mut self) { - if self.part != EscapePart::Empty { - self.push_part(); - } - self.sequence.push(EscapePart::Separator); - } - - /// Adds numbers to the in-progress part of the escape sequence - /// - /// Passes `num` as a char because `num` must be `.is_ascii_digit()` - /// [`ScreenBuffer::parse_sequence()`] builds the ascii escape sequence - /// line/column number from pushing `char`s to a string and parsing the - /// `String` to `u16`. - pub(super) fn push_num(&mut self, num: char) { - match &mut self.part { - EscapePart::Numbers(nums) => nums.push(num), - _ => self.part = EscapePart::Numbers(vec![num]), - } - } - - /// Pushes the action to the escape sequence, signaling the end - /// and results in carrying out the action for the escape sequence - /// and then resetting its values. - pub(super) fn push_action(&mut self, action: char) { - if self.part != EscapePart::Empty { - self.push_part(); - } - self.sequence.push(EscapePart::Action(action)); - } -} - -impl ScreenBuffer { - /// Parse the built [`EscapeSequence`] and runs the respective action. - pub(crate) fn parse_sequence(&mut self) { - let span = tracing::span!(tracing::Level::DEBUG, "Escape sequence"); - let _enter = span.enter(); - match &self.escape_sequence.sequence[..] { - [ - EscapePart::Numbers(x), - EscapePart::Separator, - EscapePart::Numbers(y), - EscapePart::Separator, - EscapePart::Numbers(z), - EscapePart::Action('m'), - ] => { - debug!("Got: 'ESC[{:?};{:?};{:?}m'", x, y, z); - let x: u16 = x.iter().collect::().parse().unwrap(); - let y: u16 = y.iter().collect::().parse().unwrap(); - let z: u16 = z.iter().collect::().parse().unwrap(); - if x == 0 { - self.display_attributes = Attributes::none(); - } - if y == 1 { - self.display_attributes - .set(crossterm::style::Attribute::Bold); - } - if z == 7 { - self.display_attributes - .set(crossterm::style::Attribute::Reverse); - } - self.escape_state = EscapeState::Normal; - } - [ - EscapePart::Numbers(line_nums), - EscapePart::Separator, - EscapePart::Numbers(col_nums), - EscapePart::Action(action), - ] => { - debug!("Got: 'ESC[{:?};{:?}{}'", line_nums, col_nums, action); - match action { - // Move cursor to (line_num, col_num) - 'H' | 'f' => { - // Can unwrap because it is guaranteed elsewhere that - // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). - let mut line_num: u16 = - line_nums.iter().collect::().parse().unwrap(); - let col_num: u16 = col_nums.iter().collect::().parse().unwrap(); - if line_num <= 1 { - line_num = (self.lines.len() as u16).saturating_sub(self.height); - } else { - line_num += (self.lines.len() as u16).saturating_sub(self.height); - } - self.set_cursor_pos((col_num.saturating_sub(1), line_num)); - } - _ => {} - } - self.escape_state = EscapeState::Normal; - } - [ - EscapePart::Separator, - EscapePart::Numbers(col_nums), - EscapePart::Action(action), - ] => { - debug!("Got: 'ESC[;{:?}{}'", col_nums, action); - match action { - // Move cursor to (same, col_num) - 'H' | 'f' => { - // Can unwrap because it is guaranteed elsewhere that - // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). - let col_num: u16 = col_nums.iter().collect::().parse().unwrap(); - self.cursor_pos.x = col_num.saturating_sub(1); - } - _ => {} - } - self.escape_state = EscapeState::Normal; - } - [EscapePart::Numbers(nums), EscapePart::Action(action)] => { - debug!("Got: 'ESC[{:?}{}'", nums, action); - // Can unwrap because it is guaranteed elsewhere that - // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). - let num: u16 = nums.iter().collect::().parse().unwrap(); - match (num, action) { - // Move cursor up # of lines - (num, 'A') => self.move_cursor_up(num), - // Move cursor down # of lines - (num, 'B') => self.move_cursor_down(num), - // Move cursor right # of cols - (num, 'C') => self.move_cursor_right(num), - // Move cursor left # of cols - (num, 'D') => self.move_cursor_left(num), - // Moves cursor to beginning of line, # lines down - (num, 'E') => { - self.set_cursor_pos((0, (self.cursor_pos.y as u16) + num)); - while self.cursor_pos.y > self.lines.len() { - self.lines.push_back(Line::new(self.width as usize)); - } - } - // Moves cursor to beginning of line, # lines up - (num, 'F') => self.set_cursor_pos((0, (self.cursor_pos.y as u16) - num)), - // Moves cursor to column # - (num, 'G') => self.set_cursor_col(num), - // Erase from cursor until end of screen - (0, 'J') => self.clear_from_cursor_to_eos(), - // Erase from cursor to beginning of screen - (1, 'J') => self.clear_from_cursor_to_sos(), - // Erase entire screen - (2, 'J') => self.clear_screen(), - // Erase from cursor to end of line - (0, 'K') => self.clear_from_cursor_to_eol(), - // Erase start of line to cursor - (1, 'K') => self.clear_from_cursor_to_sol(), - // Erase entire line - (2, 'K') => self.clear_whole_line(), - _ => {} - } - self.escape_state = EscapeState::Normal; - } - [EscapePart::Action(action)] => { - debug!("Got: 'ESC[{}'", action); - match action { - // Set cursor position to 0, 0 of screen - 'H' => { - self.set_cursor_pos((0, self.lines.len().saturating_sub(self.view_start))); - } - // Erase from cursor until end of screen - 'J' => self.clear_from_cursor_to_eos(), - // Erase from cursor to end of line - 'K' => self.clear_from_cursor_to_eol(), - 'C' => self.move_cursor_right(1), - 'D' => self.move_cursor_left(1), - 'm' => { - self.display_attributes = Attributes::none(); - } - action if action.is_alphabetic() => {} - _ => {} - } - self.escape_state = EscapeState::Normal; - } - other => { - debug!("Unhandled ESC: 'ESC[{:?}'", other); - self.escape_state = EscapeState::Normal; - } - } - } -} +// use crossterm::style::Attributes; +// use tracing::debug; +// +// use super::{Cursor, Line, ScreenBuffer}; +// use crate::screen_buffer::UIAction; +// +// /// `EscapeState` holds stateful information about the incoming +// /// data to allow for proper processing of ansii escape codes/characters. +// #[derive(Debug, PartialEq, Eq)] +// pub(super) enum EscapeState { +// /// Has not received ansii escape characters +// Normal, +// /// Just received an ESC (0x1B) +// Esc, +// /// Received ESC and then '[' (0x5B) +// Csi, +// } +// +// /// Represents a section of an ascii escape sequence. +// #[derive(Clone, Default, Debug, PartialEq, Eq)] +// pub(super) enum EscapePart { +// /// The default state when not actively processing an escape sequence. +// #[default] +// Empty, +// /// Collects ascii digits (0-9) as they are received individually and are +// /// eventually combined to create the final number that the `Action` will +// /// perform on i.e. `vec!['2', '3']` -> `23`. +// Numbers(Vec), +// /// The `Separator` represents the `;` used in ascii escape sequences. +// Separator, +// /// The `Action` represents the (typically) last letter of an escape +// /// sequence that determines what action is to be taken i.e. `ESC[2J`. +// Action(char), +// } +// +// /// A state-holder/collection for building ascii escape sequences +// /// from incoming data to enable proper processing/execution. +// #[derive(Clone, Debug, PartialEq, Eq)] +// pub(super) struct EscapeSequence { +// sequence: Vec, +// part: EscapePart, +// } +// +// impl EscapeSequence { +// pub(super) fn new() -> Self { +// Self { +// sequence: Vec::new(), +// part: EscapePart::Empty, +// } +// } +// +// /// Clear's the sequence and sets [`Self::part`] to [`EscapePart::Empty`]. +// pub(super) fn reset(&mut self) { +// // Clear is probably good since it will continue to +// // fill up to similar sizes throughout the program. +// self.sequence.clear(); +// self.part = EscapePart::Empty; +// } +// +// /// Appends the [`Self::part`] that is currently being processed to [`Self::sequence`]. +// fn push_part(&mut self) { +// self.sequence.push(std::mem::take(&mut self.part)); +// } +// +// /// Appends a [`EscapePart::Separator`] to [`Self::sequence`]. +// pub(super) fn insert_separator(&mut self) { +// if self.part != EscapePart::Empty { +// self.push_part(); +// } +// self.sequence.push(EscapePart::Separator); +// } +// +// /// Adds numbers to the in-progress part of the escape sequence +// /// +// /// Passes `num` as a char because `num` must be `.is_ascii_digit()` +// /// [`ScreenBuffer::parse_sequence()`] builds the ascii escape sequence +// /// line/column number from pushing `char`s to a string and parsing the +// /// `String` to `u16`. +// pub(super) fn push_num(&mut self, num: char) { +// match &mut self.part { +// EscapePart::Numbers(nums) => nums.push(num), +// _ => self.part = EscapePart::Numbers(vec![num]), +// } +// } +// +// /// Pushes the action to the escape sequence, signaling the end +// /// and results in carrying out the action for the escape sequence +// /// and then resetting its values. +// pub(super) fn push_action(&mut self, action: char) { +// if self.part != EscapePart::Empty { +// self.push_part(); +// } +// self.sequence.push(EscapePart::Action(action)); +// } +// } +// +// impl ScreenBuffer { +// /// Parse the built [`EscapeSequence`] and runs the respective action. +// pub(crate) fn parse_sequence(&mut self) { +// let span = tracing::span!(tracing::Level::DEBUG, "Escape sequence"); +// let _enter = span.enter(); +// match &self.escape_sequence.sequence[..] { +// [ +// EscapePart::Numbers(x), +// EscapePart::Separator, +// EscapePart::Numbers(y), +// EscapePart::Separator, +// EscapePart::Numbers(z), +// EscapePart::Action('m'), +// ] => { +// debug!("Got: 'ESC[{:?};{:?};{:?}m'", x, y, z); +// let x: u16 = x.iter().collect::().parse().unwrap(); +// let y: u16 = y.iter().collect::().parse().unwrap(); +// let z: u16 = z.iter().collect::().parse().unwrap(); +// if x == 0 { +// self.display_attributes = Attributes::none(); +// } +// if y == 1 { +// self.display_attributes +// .set(crossterm::style::Attribute::Bold); +// } +// if z == 7 { +// self.display_attributes +// .set(crossterm::style::Attribute::Reverse); +// } +// self.escape_state = EscapeState::Normal; +// } +// [ +// EscapePart::Numbers(line_nums), +// EscapePart::Separator, +// EscapePart::Numbers(col_nums), +// EscapePart::Action(action), +// ] => { +// debug!("Got: 'ESC[{:?};{:?}{}'", line_nums, col_nums, action); +// match action { +// // Move cursor to (line_num, col_num) +// 'H' | 'f' => { +// // Can unwrap because it is guaranteed elsewhere that +// // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). +// let mut line_num: u16 = +// line_nums.iter().collect::().parse().unwrap(); +// let col_num: u16 = col_nums.iter().collect::().parse().unwrap(); +// if line_num <= 1 { +// line_num = (self.lines.len() as u16).saturating_sub(self.height); +// } else { +// line_num += (self.lines.len() as u16).saturating_sub(self.height); +// } +// self.set_cursor_pos((col_num.saturating_sub(1), line_num)); +// } +// _ => {} +// } +// self.escape_state = EscapeState::Normal; +// } +// [ +// EscapePart::Separator, +// EscapePart::Numbers(col_nums), +// EscapePart::Action(action), +// ] => { +// debug!("Got: 'ESC[;{:?}{}'", col_nums, action); +// match action { +// // Move cursor to (same, col_num) +// 'H' | 'f' => { +// // Can unwrap because it is guaranteed elsewhere that +// // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). +// let col_num: u16 = col_nums.iter().collect::().parse().unwrap(); +// self.cursor.x = col_num.saturating_sub(1); +// } +// _ => {} +// } +// self.escape_state = EscapeState::Normal; +// } +// [EscapePart::Numbers(nums), EscapePart::Action(action)] => { +// debug!("Got: 'ESC[{:?}{}'", nums, action); +// // Can unwrap because it is guaranteed elsewhere that +// // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). +// let num: u16 = nums.iter().collect::().parse().unwrap(); +// match (num, action) { +// // Move cursor up # of lines +// (num, 'A') => self.move_cursor_up(num), +// // Move cursor down # of lines +// (num, 'B') => self.move_cursor_down(num), +// // Move cursor right # of cols +// (num, 'C') => self.move_cursor_right(num), +// // Move cursor left # of cols +// (num, 'D') => self.move_cursor_left(num), +// // Moves cursor to beginning of line, # lines down +// (num, 'E') => { +// self.set_cursor_pos((0, (self.cursor.y as u16) + num)); +// while self.cursor.y > self.lines.len() { +// self.lines.push_back(Line::new_default(self.width.into())); +// } +// } +// // Moves cursor to beginning of line, # lines up +// (num, 'F') => self.set_cursor_pos((0, (self.cursor.y as u16) - num)), +// // Moves cursor to column # +// (num, 'G') => self.set_cursor_col(num), +// // Erase from cursor until end of screen +// (0, 'J') => self.clear_from_cursor_to_eos(), +// // Erase from cursor to beginning of screen +// (1, 'J') => self.clear_from_cursor_to_sos(), +// // Erase entire screen +// (2, 'J') => self.clear_screen(), +// // Erase from cursor to end of line +// (0, 'K') => self.clear_from_cursor_to_eol(), +// // Erase start of line to cursor +// (1, 'K') => self.clear_from_cursor_to_sol(), +// // Erase entire line +// (2, 'K') => self.clear_whole_line(), +// _ => {} +// } +// self.escape_state = EscapeState::Normal; +// } +// [EscapePart::Action(action)] => { +// debug!("Got: 'ESC[{}'", action); +// match action { +// // Set cursor position to 0, 0 of screen +// 'H' => { +// self.set_cursor_pos((0, self.lines.len().saturating_sub(self.view_start))); +// } +// // Erase from cursor until end of screen +// 'J' => self.clear_from_cursor_to_eos(), +// // Erase from cursor to end of line +// 'K' => self.clear_from_cursor_to_eol(), +// 'C' => self.move_cursor_right(1), +// 'D' => self.move_cursor_left(1), +// 'm' => { +// self.display_attributes = Attributes::none(); +// } +// action if action.is_alphabetic() => {} +// _ => {} +// } +// self.escape_state = EscapeState::Normal; +// } +// other => { +// debug!("Unhandled ESC: 'ESC[{:?}'", other); +// self.escape_state = EscapeState::Normal; +// } +// } +// } +// } diff --git a/sericom-core/src/screen_buffer/mod.rs b/sericom-core/src/screen_buffer/mod.rs index 92d12ab..f32a352 100644 --- a/sericom-core/src/screen_buffer/mod.rs +++ b/sericom-core/src/screen_buffer/mod.rs @@ -17,19 +17,13 @@ //! currently, the **capacity of the [`VecDeque`] is hardcoded with a value of 10,000 //! lines with [`MAX_SCROLLBACK`]**. -mod cell; mod cursor; mod escape; -mod line; mod render; mod ui_command; -pub use cell::*; -use crossterm::style::Attributes; -pub use cursor::*; -use escape::{EscapeSequence, EscapeState}; -pub use line::*; pub use ui_command::*; +use crate::ui::{Cursor, Line, Rect}; use std::collections::VecDeque; /// The maximum number of lines stored in memory in [`ScreenBuffer`]. @@ -41,77 +35,63 @@ pub const MAX_SCROLLBACK: usize = 10000; /// of the data displayed within the terminal i.e. copy/paste, scrolling, & highlighting. #[derive(Debug)] pub struct ScreenBuffer { - /// Terminal width - width: u16, - /// Terminal height - height: u16, /// Scrollback buffer (all lines received from the serial connection). /// Limited by memory. lines: VecDeque, /// Current view into the buffer. /// Denotes which line is at the top of the screen. view_start: usize, + /// The terminal's dimensions + rect: Rect, /// Position of the cursor within the `ScreenBuffer`. - cursor_pos: Position, + cursor: crate::ui::Position, /// Start of text selection. Used for highlighting and copying to clipboard. selection_start: Option<(u16, usize)>, /// End of text selection. Used for highlighting and copying to clipboard. selection_end: Option<(u16, usize)>, /// Configuration for the maximum amount of lines to keep in memory. max_scrollback: usize, - /// Represents the current state for handling ansii escape sequences - /// as incoming data is being processed. - escape_state: EscapeState, - /// As ascii escape sequences are recieved, they are built in the - /// [`EscapeSequence`] to evaluate upon a completed escape sequence. - escape_sequence: EscapeSequence, - /// Represents the time since [`ScreenBuffer::render()`] was last called. - last_render: Option, - display_attributes: Attributes, - /// Indicates that [`ScreenBuffer`] has new data and needs to render. - needs_render: bool, } impl ScreenBuffer { /// Constructs a new `ScreenBuffer`. /// /// Takes the `width` and `height` of the terminal. - pub fn new(width: u16, height: u16) -> Self { + pub fn new(rect: Rect) -> Self { let mut buffer = Self { - width, - height, lines: VecDeque::new(), view_start: 0, - cursor_pos: Position::home(), + rect, + cursor: crate::ui::Position::ORIGIN, selection_start: None, selection_end: None, max_scrollback: MAX_SCROLLBACK, - last_render: None, - needs_render: false, - escape_state: EscapeState::Normal, - escape_sequence: EscapeSequence::new(), - display_attributes: Attributes::none(), }; // Start with an empty line - buffer.lines.push_back(Line::new(width as usize)); + buffer.lines.push_back(Line::new_default(rect.width.into())); buffer } + fn width(&self) -> u16 { + self.rect.width + } + fn set_char_at_cursor(&mut self, ch: char) { - while self.cursor_pos.y >= self.lines.len() { - self.lines.push_back(Line::new(self.width as usize)); + while usize::from(self.cursor.y) >= self.lines.len() { + self.lines + .push_back(Line::new_default(usize::from(self.width()))); } - if let Some(line) = self.lines.get_mut(self.cursor_pos.y) - && (self.cursor_pos.x as usize) < line.len() + if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) + && (self.cursor.x as usize) < line.len() { - line.set_char(self.cursor_pos.x as usize, ch); + line.set_char(self.cursor.x as usize, ch); } } fn clear_from_cursor_to_sol(&mut self) { - if let Some(line) = self.lines.get_mut(self.cursor_pos.y) { - line.reset_to(self.cursor_pos.x as usize); + if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { + line.reset_to(self.cursor.x as usize); } } @@ -119,48 +99,89 @@ impl ScreenBuffer { self.clear_from_cursor_to_sol(); for line in self .lines - .range_mut(self.view_start..=self.cursor_pos.y - 1) + .range_mut(self.view_start..usize::from(self.cursor.y)) { line.reset(); } } fn clear_from_cursor_to_eol(&mut self) { - if let Some(line) = self.lines.get_mut(self.cursor_pos.y) { - line.reset_from(self.cursor_pos.x as usize); + if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { + line.reset_from(self.cursor.x as usize); } } fn clear_from_cursor_to_eos(&mut self) { self.clear_from_cursor_to_eol(); - for line in self.lines.range_mut(self.cursor_pos.y + 1..) { + for line in self.lines.range_mut(usize::from(self.cursor.y) + 1..) { line.reset(); } } fn clear_whole_line(&mut self) { - if let Some(line) = self.lines.get_mut(self.cursor_pos.y) { + if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { line.reset(); } } fn new_line(&mut self) { - self.set_cursor_pos((0, self.cursor_pos.y + 1)); + self.set_cursor_pos((0, self.cursor.y + 1)); - if self.cursor_pos.y >= self.lines.len() { - self.lines.push_back(Line::new(self.width as usize)); + if usize::from(self.cursor.y) >= self.lines.len() { + self.lines + .push_back(Line::new_default(usize::from(self.width()))); } // Remove old lines if exceeding `ScreenBuffer.max_scrollback` while self.lines.len() > self.max_scrollback { self.lines.pop_front(); // Update the view position - if self.cursor_pos.y > 0 { - self.cursor_pos.y -= 1; + if self.cursor.y > 0 { + self.cursor.y -= 1; } if self.view_start > 0 { self.view_start -= 1; } } } + + pub(crate) fn push_line(&mut self, curr_line: Line) { + self.lines.push_back(curr_line); + } +} + +impl crate::ui::Cursor for ScreenBuffer { + /// Sets the cursor position. + fn set_cursor_pos>(&mut self, position: P) { + self.cursor = position.into(); + } + + /// Moves the cursor left by `cells`. + fn move_cursor_left(&mut self, cells: u16) { + self.cursor.x = self.cursor.x.saturating_sub(cells); + } + + /// Moves the cursor up by `lines`. + fn move_cursor_up(&mut self, lines: u16) { + self.cursor.y = self.cursor.y.saturating_sub(lines); + } + + /// Moves the cursor down by `lines`. + fn move_cursor_down(&mut self, lines: u16) { + self.cursor.y = self.cursor.y.saturating_add(lines); + while usize::from(self.cursor.y) > self.lines.len() { + self.lines + .push_back(Line::new_default(usize::from(self.width()))); + } + } + + /// Moves the cursor right by `cells`. + fn move_cursor_right(&mut self, cells: u16) { + self.cursor.x = self.cursor.x.saturating_add(cells); + } + + /// Sets the column of the cursor + fn set_cursor_col(&mut self, col: u16) { + self.cursor.x = col; + } } diff --git a/sericom-core/src/screen_buffer/render.rs b/sericom-core/src/screen_buffer/render.rs index 80d50d7..b779554 100644 --- a/sericom-core/src/screen_buffer/render.rs +++ b/sericom-core/src/screen_buffer/render.rs @@ -1,112 +1,69 @@ -use crossterm::style::Color; +#![allow(unused)] +use crossterm::style::{Attributes, Color, Colors}; use std::io::BufWriter; use tracing::instrument; -use super::{Cursor, EscapeState, Line, ScreenBuffer, UIAction}; -use crate::configs::get_config; +use super::{Cursor, ScreenBuffer, UIAction}; +use crate::{ + configs::get_config, + ui::{ByteParser, Cell, Line, NL, ParserEvent, Span}, +}; const MIN_RENDER_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_millis(33); +#[derive(Debug, PartialEq, Eq)] +pub struct TestLine(Vec); +impl TestLine { + fn new() -> Self { + TestLine(vec![]) + } + fn push(&mut self, event: ParserEvent) { + self.0.push(event); + } +} + impl ScreenBuffer { /// Takes incoming data (bytes (`u8`) from a serial connection) and /// processes them accordingly, handling ascii escape sequences, to /// render as characters/strings in the terminal. #[instrument(name = "Add Data", skip(self, data))] - pub fn add_data(&mut self, data: &[u8]) { - let text = String::from_utf8_lossy(data); - let mut chars = text.chars().peekable(); - - while let Some(ch) = chars.next() { - match self.escape_state { - EscapeState::Normal => { - match ch { - '\r' => { - self.cursor_pos.x = 0; - if chars.peek() == Some(&'\n') { - chars.next(); - self.new_line(); - } - } - '\n' => { - self.new_line(); - } - '\x07' => {} - '\x0E' => {} - '\x0F' => {} - '\x08' => { - let mut temp_chars = chars.clone(); - // Matches the `\x08 ' ' \x08` deletion sequence - if let (Some(' '), Some('\x08')) = - (temp_chars.next(), temp_chars.next()) - { - // Consume them - to remove from further processing - chars.next(); - chars.next(); - self.move_cursor_left(1); - self.set_char_at_cursor(' '); - } else { - // If not the deletion sequence, move cursor left - // when receiving a single '\x08' - self.move_cursor_left(1); - } - } - '\x1B' => self.escape_state = EscapeState::Esc, - c => { - let mut batch = vec![c]; - while let Some(&next_ch) = chars.peek() { - if next_ch.is_control() - || next_ch == '\x1B' - || self.cursor_pos.x + batch.len() as u16 >= self.width - { - break; - } - batch.push(chars.next().unwrap()); - } - self.add_char_batch(&batch); - } - } - } - EscapeState::Esc => match ch { - '[' => self.escape_state = EscapeState::Csi, - _ => self.escape_state = EscapeState::Normal, - }, - EscapeState::Csi => match ch { - ';' => self.escape_sequence.insert_separator(), - c if ch.is_ascii_digit() => self.escape_sequence.push_num(c), - c if c.is_ascii_alphabetic() => { - // Reset because actions are the last members of a sequence - self.escape_sequence.push_action(c); - self.parse_sequence(); - self.escape_sequence.reset(); - self.escape_state = EscapeState::Normal; - } - // NOTE: May need to handle '?', ':', and '>' - _ => self.escape_state = EscapeState::Normal, - }, - } - } - // Sets `self.needs_render = true` - self.scroll_to_bottom(); + pub fn add_data(&mut self, parser: &mut ByteParser, data: &[u8]) { + let events = parser.feed(data); + // self.process_events(writer, event); } - fn add_char_batch(&mut self, chars: &[char]) { - tracing::debug!("CharBatch: '{:?}'", chars); - while self.cursor_pos.y >= self.lines.len() { - self.lines.push_back(Line::new(self.width as usize)); - } - - if let Some(line) = self.lines.get_mut(self.cursor_pos.y) { - for &ch in chars { - line.set_char(self.cursor_pos.x as usize, ch); - self.cursor_pos.x += 1; - if self.cursor_pos.x >= self.width { - self.new_line(); - break; - } + fn add_lines(&mut self, parsed_events: Vec) { + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + let mut curr_colors: Colors = Colors::new(fg, bg); + let mut curr_line = TestLine::new(); + for event in parsed_events { + if event == ParserEvent::Control(NL) { + self.lines.push_back(curr_line); } + curr_line.push(event); } } + // fn add_char_batch(&mut self, chars: &[char]) { + // tracing::debug!("CharBatch: '{:?}'", chars); + // while self.cursor.y >= self.lines.len() { + // self.lines.push_back(Line::new_default(self.width.into())); + // } + // + // if let Some(line) = self.lines.get_mut(self.cursor.y) { + // for &ch in chars { + // line.set_char(self.cursor.x as usize, ch); + // self.cursor.x += 1; + // if self.cursor.x >= self.width { + // self.new_line(); + // break; + // } + // } + // } + // } + /// A helper function to check whether the terminal's screen should be rendered. pub fn should_render_now(&self) -> bool { use tokio::time::Instant; @@ -131,6 +88,7 @@ impl ScreenBuffer { /// /// Because of this, the only diff-ing that would make sense would be /// that of the cells within the screen that are simply blank. + #[allow(clippy::similar_names)] pub fn render(&mut self) -> std::io::Result<()> { use crossterm::{cursor, queue, style}; use std::io::{self, Write}; @@ -206,17 +164,17 @@ impl ScreenBuffer { // This is relative the the terminal's L x W, whereas // self.cursor_pos.y is within the entire line buf - let screen_cursor_y = if self.cursor_pos.y >= self.view_start - && self.cursor_pos.y < self.view_start + self.height as usize + let screen_cursor_y = if self.cursor.y >= self.view_start + && self.cursor.y < self.view_start + self.height as usize { - (self.cursor_pos.y - self.view_start) as u16 + (self.cursor.y - self.view_start) as u16 } else { self.height - 1 }; queue!( writer, - cursor::MoveTo(self.cursor_pos.x, screen_cursor_y), + cursor::MoveTo(self.cursor.x, screen_cursor_y), cursor::Show )?; writer.flush()?; diff --git a/sericom-core/src/screen_buffer/ui_command.rs b/sericom-core/src/screen_buffer/ui_command.rs index f176c76..61e0e91 100644 --- a/sericom-core/src/screen_buffer/ui_command.rs +++ b/sericom-core/src/screen_buffer/ui_command.rs @@ -121,14 +121,14 @@ impl UIAction for ScreenBuffer { self.lines.clear(); self.view_start = 0; self.set_cursor_pos((0_u16, 0_usize)); - self.lines.push_back(Line::new(self.width as usize)); + self.lines.push_back(Line::new_default(self.width.into())); self.needs_render = true; } /// Clears the current *visible* screen while keeping the buffer's history fn clear_screen(&mut self) { for _ in 0..self.height { - self.lines.push_back(Line::new(self.width as usize)); + self.lines.push_back(Line::new_default(self.width.into())); } self.view_start = self.lines.len().saturating_sub(self.height as usize); self.needs_render = true; diff --git a/sericom-core/src/serial_actor/mod.rs b/sericom-core/src/serial_actor/mod.rs index fd83b5f..4ebf92f 100644 --- a/sericom-core/src/serial_actor/mod.rs +++ b/sericom-core/src/serial_actor/mod.rs @@ -1,6 +1,7 @@ //! This module holds all of the code directly responsible for interacting //! with the serial connection and tasks within the program. +pub mod parser; pub mod tasks; /// Represents messages/commands that are sent from worker tasks diff --git a/sericom-core/src/serial_actor/parser.rs b/sericom-core/src/serial_actor/parser.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sericom-core/src/serial_actor/parser.rs @@ -0,0 +1 @@ + diff --git a/sericom-core/src/serial_actor/tasks.rs b/sericom-core/src/serial_actor/tasks.rs index c507057..2853590 100644 --- a/sericom-core/src/serial_actor/tasks.rs +++ b/sericom-core/src/serial_actor/tasks.rs @@ -1,5 +1,8 @@ -use super::*; -use crate::screen_buffer::*; +use crate::{ + screen_buffer::*, + serial_actor::{SerialEvent, SerialMessage, parser::ByteParser}, + ui::{Rect, Terminal}, +}; use crossterm::{ event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}, terminal, @@ -29,7 +32,11 @@ pub async fn run_stdout_output( mut ui_rx: tokio::sync::mpsc::Receiver, ) { let (width, height) = terminal::size().unwrap_or((80, 24)); - let mut screen_buffer = ScreenBuffer::new(width, height); + let mut screen_buffer = ScreenBuffer::new(Rect { + width, + height, + origin: crate::ui::Position::ORIGIN, + }); let mut data_buffer = Vec::with_capacity(2048); let mut render_timer: Option = None; @@ -43,22 +50,19 @@ pub async fn run_stdout_output( if data_buffer.len() > 1024 || data.contains(&b'\n') { screen_buffer.add_data(&data_buffer); data_buffer.clear(); - if screen_buffer.should_render_now() { screen_buffer.render().ok(); render_timer = None; } else if render_timer.is_none() { render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); } - } else { + } else if screen_buffer.should_render_now() { screen_buffer.add_data(&data_buffer); data_buffer.clear(); - if screen_buffer.should_render_now() { - screen_buffer.render().ok(); - } else if render_timer.is_none() { - render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); - } + screen_buffer.render().ok(); + } else if render_timer.is_none() { + render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); } } Ok(SerialEvent::Error(e)) => { @@ -68,8 +72,7 @@ pub async fn run_stdout_output( screen_buffer.render().ok(); render_timer = None; } - Ok(SerialEvent::ConnectionClosed) => break, - Err(_) => break, + Ok(SerialEvent::ConnectionClosed) | Err(_) => break, } } ui_command = ui_rx.recv() => { @@ -104,11 +107,11 @@ pub async fn run_stdout_output( screen_buffer.render().ok(); render_timer = None; } - _ = async { + () = async { if let Some(ref mut timer) = render_timer { timer.tick().await; } else { - std::future::pending::<()>().await + std::future::pending::<()>().await; } } => { if screen_buffer.should_render_now() { @@ -146,6 +149,7 @@ pub async fn run_stdin_input( } } +#[allow(clippy::too_many_lines)] #[instrument(skip_all, name = "Stdin Input")] fn stdin_input_loop( stdin_tx: tokio::sync::mpsc::Sender, @@ -174,7 +178,6 @@ fn stdin_input_loop( } _ => {} }; - continue; } // Match Alt + Code Event::Key(KeyEvent { @@ -189,7 +192,6 @@ fn stdin_input_loop( if let KeyCode::Char('b') = code { let _ = command_tx.blocking_send(SerialMessage::SendBreak); }; - continue; } // Match Control + Code Event::Key(KeyEvent { @@ -214,7 +216,6 @@ fn stdin_input_loop( } _ => {} }; - continue; } // Match every other key Event::Key(KeyEvent { @@ -341,7 +342,6 @@ pub async fn run_file_output( } Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { eprintln!("File writer lagged, skipped {skipped} messages"); - continue; // Don't break on lag } _ => break, } @@ -362,3 +362,107 @@ pub async fn run_file_output( let _ = data_streamer.await; let _ = write_handle.await; } + +pub async fn _run_stdout_output( + mut con_rx: tokio::sync::broadcast::Receiver, + mut ui_rx: tokio::sync::mpsc::Receiver, +) { + let mut terminal = Terminal::default(); + let screen_buffer = ScreenBuffer::new(terminal.area()); + let mut data_buffer: Vec = Vec::with_capacity(2048); + let mut render_timer: Option = None; + + ///////// + let mut parser = ByteParser::new(); + + terminal.draw(|frame| { + frame.render_widget(screen_buffer, frame.area()); + }); + + loop { + tokio::select! { + serial_event = con_rx.recv() => { + match serial_event { + Ok(SerialEvent::Data(data)) => { + data_buffer.extend_from_slice(&data); + // screen_buffer.add_data(&data_buffer); + // data_buffer.clear(); + if data_buffer.len() > 1024 || data.contains(&b'\n') { + let parsed = parser.feed(&data_buffer); + tracing::debug!("{:?}", parsed); + data_buffer.clear(); + + // if screen_buffer.should_render_now() { + // screen_buffer.render().ok(); + // render_timer = None; + // } else if render_timer.is_none() { + // render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); + // } + } + // } else if &screen_buffer.should_render_now() { + // let parsed = parser.feed(&data_buffer); + // tracing::debug!("{:?}", parsed); + // data_buffer.clear(); + // + // screen_buffer.render().ok(); + // } else if render_timer.is_none() { + // render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); + // } + } + Ok(SerialEvent::Error(_)) => { + // let error_msg = format!("[ERROR] {e}\r\n"); + // error!("Added data: {:?}", data_buffer); + // screen_buffer.add_data(error_msg.as_bytes()); + // screen_buffer.render().ok(); + // render_timer = None; + } + Ok(SerialEvent::ConnectionClosed) | Err(_) => break, + } + } + ui_command = ui_rx.recv() => { + // debug!("Sending UICommand: {:?}", ui_command); + // match ui_command { + // Some(UICommand::ScrollUp(lines)) => { + // screen_buffer.scroll_up(lines); + // } + // Some(UICommand::ScrollDown(lines)) => { + // screen_buffer.scroll_down(lines); + // } + // Some(UICommand::ScrollTop) => { + // screen_buffer.scroll_to_top(); + // } + // Some(UICommand::ScrollBottom) => { + // screen_buffer.scroll_to_bottom(); + // } + // Some(UICommand::StartSelection(pos)) => { + // screen_buffer.start_selection(pos); + // } + // Some(UICommand::UpdateSelection(pos)) => { + // screen_buffer.update_selection(pos); + // } + // Some(UICommand::CopySelection) => { + // screen_buffer.copy_to_clipboard().ok(); + // } + // Some(UICommand::ClearBuffer) => { + // screen_buffer.clear_buffer(); + // } + // None => break, + // } + // screen_buffer.render().ok(); + // render_timer = None; + } + () = async { + if let Some(ref mut timer) = render_timer { + timer.tick().await; + } else { + std::future::pending::<()>().await; + } + } => { + // if screen_buffer.should_render_now() { + // screen_buffer.render().ok(); + // render_timer = None; + // } + } + } + } +} diff --git a/sericom-core/src/ui/ascii/mod.rs b/sericom-core/src/ui/ascii/mod.rs new file mode 100644 index 0000000..231060c --- /dev/null +++ b/sericom-core/src/ui/ascii/mod.rs @@ -0,0 +1,23 @@ +mod parser; +pub mod process; + +pub(crate) use parser::*; + +/// Bracket '[' +pub(crate) const BK: u8 = b'['; +/// Backspace +pub(crate) const BS: u8 = 0x08; +/// Carrige return '\r' +pub(crate) const CR: u8 = 0x0D; +/// Escape 'ESC' +pub(crate) const ESC: u8 = 0x1B; +/// Newline '\n' +pub(crate) const NL: u8 = 0x0A; +/// Escape sequence separator ';' +pub(crate) const SEP: u8 = b';'; +/// Tab '\t' +pub(crate) const TAB: u8 = 0x09; +/// Form feed +pub(crate) const FF: u8 = 0x0C; +/// Reset graphics mode escape sequence +pub(crate) const RESET: &[u8] = &[ESC, BK, b'0', b'm']; diff --git a/sericom-core/src/ui/ascii/parser.rs b/sericom-core/src/ui/ascii/parser.rs new file mode 100644 index 0000000..75bc672 --- /dev/null +++ b/sericom-core/src/ui/ascii/parser.rs @@ -0,0 +1,90 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ParserEvent { + Text(Vec), + Control(u8), + EscapeSequence(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ParseState { + Normal, + Esc, + Csi, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ByteParser { + state: ParseState, + buffer: Vec, +} + +impl ByteParser { + pub(crate) const fn new() -> Self { + Self { + state: ParseState::Normal, + buffer: vec![], + } + } +} + +impl ByteParser { + pub(crate) fn feed(&mut self, data: &[u8]) -> Vec { + let mut events: Vec = Vec::new(); + + for &b in data { + if !b.is_ascii() { + continue; + } + match self.state { + ParseState::Normal => match b { + 0x1B => { + if !self.buffer.is_empty() { + events.push(ParserEvent::Text(std::mem::take(&mut self.buffer))); + } + self.buffer.push(b); + self.state = ParseState::Esc; + } + 0x00..=0x1F | 0x7F => { + if !self.buffer.is_empty() { + events.push(ParserEvent::Text(std::mem::take(&mut self.buffer))); + } + events.push(ParserEvent::Control(b)); + } + // Regular text + _ => { + self.buffer.push(b); + } + }, + ParseState::Esc => { + self.buffer.push(b); + if b == b'[' { + self.state = ParseState::Csi; + } else { + // Incomplete escape sequence but push as escape sequence anyway if + // it is nonesense - the consumer will not do anything with it later. + events.push(ParserEvent::EscapeSequence(std::mem::take( + &mut self.buffer, + ))); + self.state = ParseState::Normal; + } + } + ParseState::Csi => { + self.buffer.push(b); + // Csi is terminated by a regular letter [a-z][A-Z] + if b.is_ascii_alphabetic() { + events.push(ParserEvent::EscapeSequence(std::mem::take( + &mut self.buffer, + ))); + self.state = ParseState::Normal; + } + } + } + } + + // Flush buffer if it is regular text + if self.state == ParseState::Normal && !self.buffer.is_empty() { + events.push(ParserEvent::Text(std::mem::take(&mut self.buffer))); + } + events + } +} diff --git a/sericom-core/src/ui/ascii/process.rs b/sericom-core/src/ui/ascii/process.rs new file mode 100644 index 0000000..f73576c --- /dev/null +++ b/sericom-core/src/ui/ascii/process.rs @@ -0,0 +1,168 @@ +use std::io::Write; + +use crossterm::{ + cursor, queue, + style::{Attribute, Print, SetAttribute}, + terminal::{Clear, ClearType}, +}; +use miette::IntoDiagnostic; + +use super::*; +use crate::{ + screen_buffer::ScreenBuffer, + ui::{Cell, Cursor, Line, Span, line::ColorState}, +}; + +impl ScreenBuffer { + pub fn process_events(&mut self, events: Vec) -> miette::Result<()> { + let mut color_state = ColorState::default(); + + let mut curr_span = Span::default(); + let mut curr_line = Line::new_default(40); + + for ev in events { + match &ev { + ParserEvent::Text(t) => { + t.iter().for_each(|c| { + curr_span.push(Cell::new(char::from(*c))); + }); + } + ParserEvent::Control(b) => { + match *b { + BS => self.move_cursor_left(1), + CR => self.set_cursor_col(0), + // Need to handle creating new empty buffer + FF => todo!(), + // FF => queue!(writer, Clear(ClearType::All)).into_diagnostic()?, + NL => { + if !curr_span.is_empty() { + curr_line.push(std::mem::take(&mut curr_span)); + } + self.push_line(std::mem::take(&mut curr_line)); + } + TAB => todo!(), + _ => {} + } + } + ParserEvent::EscapeSequence(seq) => { + // Verify it is a color sequence 'ESC[_m' + if seq.len() >= 3 + && seq[0] == ESC + && seq[1] == BK + && *seq.last().unwrap() == b'm' + { + extract_color_seq(writer, &mut color_state, seq)?; + } + } + } + } + writer.flush().ok(); + Ok(()) + } +} + +fn extract_color_seq( + writer: &mut W, + color_state: &mut ColorState, + seq: &[u8], +) -> miette::Result<()> { + // Get the part between 'ESC[' and 'm' + let body = &seq[2..seq.len() - 1]; + + // If none, it is just setting a graphics mode + let Some(first_part_idx) = body.iter().position(|&b| b == SEP) else { + process_graphics_mode(writer, color_state, body)?; + return Ok(()); + }; + + let (mode, color_bytes) = body.split_at(first_part_idx); + + // If true, color_str skips the graphics_mode part + let color_str = if process_graphics_mode(writer, color_state, mode)? { + &color_bytes[1..] + } else { + body + }; + + if let Ok(color_str) = str::from_utf8(color_str) { + color_state.set_colors(color_str); + } + + Ok(()) +} + +fn process_graphics_mode( + writer: &mut W, + color_state: &mut ColorState, + body: &[u8], +) -> miette::Result { + let attr = match *body { + [b'0'] => { + *color_state = ColorState::default(); + Attribute::Reset + } + [b'1'] => Attribute::Bold, + [b'2'] => Attribute::Dim, + [b'3'] => Attribute::Italic, + [b'4'] => Attribute::Underlined, + [b'5'] => Attribute::SlowBlink, + [b'7'] => Attribute::Reverse, + [b'8'] => Attribute::Hidden, + [b'9'] => Attribute::CrossedOut, + [b'2', b'2'] => Attribute::NoBold, + [b'2', b'3'] => Attribute::NoItalic, + [b'2', b'4'] => Attribute::NoUnderline, + [b'2', b'5'] => Attribute::NoBlink, + [b'2', b'7'] => Attribute::NoReverse, + [b'2', b'8'] => Attribute::NoHidden, + [b'2', b'9'] => Attribute::NotCrossedOut, + _ => { + return Ok(false); + } // ignore the rest + }; + if *body == [b'2', b'2'] { + queue!( + writer, + SetAttribute(attr), + SetAttribute(Attribute::NormalIntensity) + ) + .into_diagnostic()?; + } else { + queue!(writer, SetAttribute(attr)).into_diagnostic()?; + } + Ok(true) +} +// pub fn process_events( +// writer: &mut W, +// events: Vec, +// ) -> miette::Result<()> { +// let mut color_state = ColorState::default(); +// for ev in events { +// match &ev { +// ParserEvent::Text(t) => { +// if let Ok(s) = std::str::from_utf8(t) { +// color_state.queue_line(writer, s)?; +// } +// } +// ParserEvent::Control(b) => { +// match *b { +// BS => queue!(writer, cursor::MoveLeft(1)).into_diagnostic()?, +// CR => queue!(writer, cursor::MoveToColumn(0)).into_diagnostic()?, +// // Need to handle creating new empty buffer +// FF => queue!(writer, Clear(ClearType::All)).into_diagnostic()?, +// NL => queue!(writer, Print("\n")).into_diagnostic()?, +// TAB => queue!(writer, Print("\t")).into_diagnostic()?, +// _ => {} +// } +// } +// ParserEvent::EscapeSequence(seq) => { +// // Verify it is a color sequence 'ESC[_m' +// if seq.len() >= 3 && seq[0] == 0x1B && seq[1] == b'[' && *seq.last().unwrap() == b'm' { +// extract_color_seq(writer, &mut color_state, seq)?; +// } +// } +// } +// } +// writer.flush().ok(); +// Ok(()) +// } diff --git a/sericom-core/src/ui/buffer.rs b/sericom-core/src/ui/buffer.rs new file mode 100644 index 0000000..590de57 --- /dev/null +++ b/sericom-core/src/ui/buffer.rs @@ -0,0 +1,29 @@ +#![allow(unused)] +use super::Line; +use super::Rect; +use crate::screen_buffer::Cell; + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct Buffer { + area: Rect, + content: Vec, +} +impl Buffer { + pub fn reset(&mut self) { + for line in &mut self.content { + line.reset(); + } + } + #[must_use] + pub fn empty(area: Rect) -> Self { + Self::filled(area, Cell::EMPTY) + } + + #[must_use] + pub fn filled(area: Rect, cell: Cell) -> Self { + let line = Line::new(area.width.into(), cell); + let size = area.height as usize; + let content = vec![line; size]; + Self { area, content } + } +} diff --git a/sericom-core/src/ui/frame.rs b/sericom-core/src/ui/frame.rs new file mode 100644 index 0000000..e339f8e --- /dev/null +++ b/sericom-core/src/ui/frame.rs @@ -0,0 +1,37 @@ +#![allow(unused)] + +use crate::{screen_buffer::ScreenBuffer, ui::Buffer}; + +use super::{position::Position, rect::Rect}; + +#[derive(Debug)] +pub struct Frame<'a> { + pub(crate) buffer: &'a mut Buffer, + pub(crate) cursor_position: Option, + pub(crate) area: Rect, +} + +impl Frame<'_> { + #[must_use] + pub const fn area(&self) -> Rect { + self.area + } + + pub fn render_widget(&mut self, widget: W, area: Rect) { + widget.render(area, self.buffer); + } +} + +pub trait Widget { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized; +} + +impl Widget for ScreenBuffer { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + } +} diff --git a/sericom-core/src/screen_buffer/cell.rs b/sericom-core/src/ui/line/cell.rs similarity index 58% rename from sericom-core/src/screen_buffer/cell.rs rename to sericom-core/src/ui/line/cell.rs index 9dba7fb..c9945cc 100644 --- a/sericom-core/src/screen_buffer/cell.rs +++ b/sericom-core/src/ui/line/cell.rs @@ -6,23 +6,42 @@ use crate::configs::get_config; /// /// Used to hold rendering state for all the cells within the [`ScreenBuffer`][`super::ScreenBuffer`]. /// Each line within [`ScreenBuffer`][`super::ScreenBuffer`] is represented by a `Vec`. -#[derive(Clone, Debug)] +#[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct Cell { - pub(super) character: char, - pub(super) fg_color: Color, - pub(super) bg_color: Color, - pub(super) is_selected: bool, + pub(crate) character: char, + pub(crate) is_selected: bool, +} + +impl Cell { + pub const EMPTY: Self = Self::new(' '); + + #[must_use] + pub const fn new(character: char) -> Self { + Self { + character, + is_selected: false, + } + } +} + +impl From for Cell { + fn from(value: char) -> Self { + Self::new(value) + } +} + +impl From for char { + fn from(value: Cell) -> Self { + value.character + } } impl Default for Cell { /// The default for [`Cell`] is the fg color from [`Appearance.fg`][`crate::configs::Appearance`], /// the bg color from [`Appearance.bg`][`crate::configs::Appearance`], `' '` for the character, and is not selected. fn default() -> Self { - let config = get_config(); Self { character: ' ', - fg_color: Color::from(&config.appearance.fg), - bg_color: Color::from(&config.appearance.bg), is_selected: false, } } diff --git a/sericom-core/src/ui/line/color_state.rs b/sericom-core/src/ui/line/color_state.rs new file mode 100644 index 0000000..1577ce9 --- /dev/null +++ b/sericom-core/src/ui/line/color_state.rs @@ -0,0 +1,35 @@ +use crossterm::{ + queue, + style::{Color, Colored, Colors, Print, SetColors}, +}; +use miette::IntoDiagnostic; +use std::io::Write; + +pub struct ColorState { + colors: Colors, +} + +impl Default for ColorState { + fn default() -> Self { + Self { + colors: Colors::new(Color::Green, Color::Reset), + } + } +} + +impl ColorState { + pub fn get_colors(&self) -> Colors { + self.colors + } + pub fn queue_line(&self, writer: &mut W, text: &str) -> miette::Result<()> { + queue!(writer, SetColors(self.colors), Print(text)).into_diagnostic()?; + Ok(()) + } + + pub fn set_colors(&mut self, ascii_str: &str) { + self.colors = match Colored::parse_ansi(ascii_str) { + Some(colored) => self.colors.then(&colored.into()), + None => self.colors, + }; + } +} diff --git a/sericom-core/src/screen_buffer/line.rs b/sericom-core/src/ui/line/line.rs similarity index 58% rename from sericom-core/src/screen_buffer/line.rs rename to sericom-core/src/ui/line/line.rs index b59b6d6..0611216 100644 --- a/sericom-core/src/screen_buffer/line.rs +++ b/sericom-core/src/ui/line/line.rs @@ -1,16 +1,34 @@ -use super::Cell; use std::ops::{Index, IndexMut}; +use crate::ui::Span; + /// Line is a wrapper around [`Vec`] and represents a line within the [`ScreenBuffer`][`super::ScreenBuffer`]. -#[derive(Clone, Debug)] -pub struct Line(Vec); +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Line(Vec); impl Line { + /// Create a new line with the length/size of `width`. + /// + /// Filled with `span`. + #[must_use] + pub fn new(width: usize, span: Span) -> Self { + Self(vec![span; width]) + } + /// Create a new line with the length/size of `width`. /// /// Filled with [`Cell::default()`]. - pub fn new(width: usize) -> Self { - Self(vec![Cell::default(); width]) + #[must_use] + pub fn new_default(width: usize) -> Self { + Self(vec![Span::default(); width]) + } + + /// Create a new line with the length/size of `width`. + /// + /// Filled with [`Cell::EMPTY`]. + #[must_use] + pub fn new_empty(width: usize) -> Self { + Self(vec![Span::default(); width]) } /// Iterates over all the [`Cell`]s within the line and sets them to [`Cell::default()`]. @@ -41,6 +59,7 @@ impl Line { } /// Util function to return the length of [`Self`]. + #[must_use] #[allow(clippy::len_without_is_empty)] pub const fn len(&self) -> usize { self.0.len() @@ -48,22 +67,51 @@ impl Line { /// Iterates over the [`Cell`]s and resets their selected state. pub fn clear_selection(&mut self) { - self.0.iter_mut().for_each(|cell| cell.is_selected = false); + self.0 + .iter_mut() + .for_each(|span| span.iter_mut().for_each(|cell| cell.is_selected = false)); } /// Returns a reference to [`Cell`] at `idx`. - pub fn get_cell(&self, idx: usize) -> Option<&Cell> { + #[must_use] + pub fn get_span(&self, idx: usize) -> Option<&Span> { self.0.get(idx) } /// Returns a mutable reference to [`Cell`] at `idx`. - pub fn get_mut_cell(&mut self, idx: usize) -> Option<&mut Cell> { + pub fn get_mut_span(&mut self, idx: usize) -> Option<&mut Span> { self.0.get_mut(idx) } + + pub fn count_cells(&self) -> usize { + let mut num_cells = 0; + self.0.iter().for_each(|span| { + num_cells += span.count(); + }); + num_cells + } + + pub fn iter(&self) -> std::slice::Iter<'_, Span> { + self.0.iter() + } + + pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Span> { + self.0.iter_mut() + } + + pub fn push(&mut self, span: Span) { + self.0.push(span); + } +} + +impl Default for Line { + fn default() -> Self { + Self(Default::default()) + } } impl IntoIterator for Line { - type Item = Cell; + type Item = Span; type IntoIter = std::vec::IntoIter; fn into_iter(self) -> Self::IntoIter { @@ -72,8 +120,8 @@ impl IntoIterator for Line { } impl<'a> IntoIterator for &'a Line { - type Item = &'a Cell; - type IntoIter = std::slice::Iter<'a, Cell>; + type Item = &'a Span; + type IntoIter = std::slice::Iter<'a, Span>; fn into_iter(self) -> Self::IntoIter { self.0.iter() @@ -81,8 +129,8 @@ impl<'a> IntoIterator for &'a Line { } impl<'a> IntoIterator for &'a mut Line { - type Item = &'a mut Cell; - type IntoIter = std::slice::IterMut<'a, Cell>; + type Item = &'a mut Span; + type IntoIter = std::slice::IterMut<'a, Span>; fn into_iter(self) -> Self::IntoIter { self.0.iter_mut() @@ -90,7 +138,7 @@ impl<'a> IntoIterator for &'a mut Line { } impl Index for Line { - type Output = Cell; + type Output = Span; fn index(&self, index: usize) -> &Self::Output { &self.0[index] } diff --git a/sericom-core/src/ui/line/mod.rs b/sericom-core/src/ui/line/mod.rs new file mode 100644 index 0000000..6cab228 --- /dev/null +++ b/sericom-core/src/ui/line/mod.rs @@ -0,0 +1,8 @@ +mod cell; +mod color_state; +mod line; +mod span; +pub(crate) use cell::*; +pub(crate) use color_state::*; +pub(crate) use line::*; +pub(crate) use span::*; diff --git a/sericom-core/src/ui/line/span.rs b/sericom-core/src/ui/line/span.rs new file mode 100644 index 0000000..c476fef --- /dev/null +++ b/sericom-core/src/ui/line/span.rs @@ -0,0 +1,63 @@ +use std::ops::{Index, IndexMut}; + +use crossterm::style::{Attributes, Color, Colors}; + +use crate::{ + configs::get_config, + ui::{Cell, ColorState}, +}; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Span { + cells: Vec, + attrs: Attributes, + colors: Colors, +} + +impl Default for Span { + fn default() -> Self { + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + Self { + cells: Vec::default(), + attrs: Attributes::default(), + colors: Colors::new(fg, bg), + } + } +} + +impl Span { + pub(crate) fn push(&mut self, cell: Cell) { + self.cells.push(cell); + } + pub(crate) fn count(&self) -> usize { + self.cells.iter().count() + } + pub(crate) fn set_colors(&mut self, colors: &ColorState) { + self.colors = colors.get_colors(); + } + pub(crate) fn is_empty(&self) -> bool { + self.cells.is_empty() + } + pub fn iter(&self) -> std::slice::Iter<'_, Cell> { + self.cells.iter() + } + + pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Cell> { + self.cells.iter_mut() + } +} + +impl Index for Span { + type Output = Cell; + fn index(&self, index: usize) -> &Self::Output { + &self.cells[index] + } +} + +impl IndexMut for Span { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + &mut self.cells[index] + } +} diff --git a/sericom-core/src/ui/mod.rs b/sericom-core/src/ui/mod.rs new file mode 100644 index 0000000..11c538e --- /dev/null +++ b/sericom-core/src/ui/mod.rs @@ -0,0 +1,15 @@ +mod ascii; +mod buffer; +mod frame; +mod line; +mod position; +mod rect; +mod terminal; + +pub(crate) use ascii::*; +pub(crate) use buffer::Buffer; +pub(crate) use frame::Frame; +pub use line::*; +pub(crate) use position::{Cursor, Position}; +pub(crate) use rect::Rect; +pub(crate) use terminal::Terminal; diff --git a/sericom-core/src/ui/position.rs b/sericom-core/src/ui/position.rs new file mode 100644 index 0000000..29305de --- /dev/null +++ b/sericom-core/src/ui/position.rs @@ -0,0 +1,82 @@ +#![allow(unused)] + +use std::fmt::Display; + +use crate::screen_buffer::ScreenBuffer; + +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] +pub(crate) struct Position { + pub(crate) x: u16, + pub(crate) y: u16, +} + +impl Position { + pub const ORIGIN: Self = Self { x: 0, y: 0 }; + + pub const fn set_y(&mut self, y: u16) { + self.y = y; + } + pub const fn set_x(&mut self, x: u16) { + self.x = x; + } + pub fn set_pos_from>(&mut self, pos: P) { + let p = pos.into(); + self.x = p.x; + self.y = p.y; + } + pub const fn set_pos(&mut self, pos: Self) { + *self = pos; + } + pub const fn get_x(&self) -> u16 { + self.x + } + pub const fn get_y(&self) -> u16 { + self.y + } +} + +impl From<(u16, u16)> for Position { + fn from((x, y): (u16, u16)) -> Self { + Self { x, y } + } +} + +impl From<(u16, usize)> for Position { + fn from((x, y): (u16, usize)) -> Self { + let y: u16 = y.try_into().expect("Out of scrollback buffer bounds"); + Self { x, y } + } +} + +impl From for (u16, usize) { + fn from(position: Position) -> Self { + (position.x, usize::from(position.y)) + } +} + +impl From for (usize, u16) { + fn from(position: Position) -> Self { + (usize::from(position.x), position.y) + } +} + +impl From for (u16, u16) { + fn from(position: Position) -> Self { + (position.x, position.y) + } +} + +impl Display for Position { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + +pub trait Cursor { + fn set_cursor_pos>(&mut self, position: P); + fn move_cursor_left(&mut self, cells: u16); + fn move_cursor_up(&mut self, lines: u16); + fn move_cursor_down(&mut self, lines: u16); + fn move_cursor_right(&mut self, cells: u16); + fn set_cursor_col(&mut self, col: u16); +} diff --git a/sericom-core/src/ui/rect.rs b/sericom-core/src/ui/rect.rs new file mode 100644 index 0000000..86f5b82 --- /dev/null +++ b/sericom-core/src/ui/rect.rs @@ -0,0 +1,72 @@ +#![allow(unused)] +use std::cmp::{max, min}; + +use super::Position; + +// #[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] +pub(crate) struct Rect { + pub(crate) width: u16, + pub(crate) height: u16, + pub(crate) origin: Position, +} + +impl Rect { + #[must_use] + pub const fn new(origin: Position, width: u16, height: u16) -> Self { + Self { + width, + height, + origin, + } + } + + #[must_use] + pub const fn left(&self) -> u16 { + self.origin.x + } + + #[must_use] + pub const fn right(&self) -> u16 { + self.origin.x.saturating_add(self.width) + } + + #[must_use] + pub const fn top(&self) -> u16 { + self.origin.y + } + + #[must_use] + pub const fn bottom(&self) -> u16 { + self.origin.y.saturating_add(self.height) + } + + #[must_use] + pub const fn area(&self) -> u16 { + self.width.saturating_mul(self.height) + } + + #[must_use] + pub fn intersection(&self, other: Self) -> Self { + let x1 = max(self.left(), other.left()); + let x2 = min(self.right(), other.right()); + let y1 = max(self.top(), other.top()); + let y2 = min(self.bottom(), other.bottom()); + Self { + origin: (x1, y1).into(), + width: x2.saturating_sub(x1), + height: y2.saturating_sub(y1), + } + } +} + +impl From<(u16, u16)> for Rect { + /// Creates a new `Rect` with (width, height) at [`Position::ORIGIN`] + fn from(value: (u16, u16)) -> Self { + Self { + width: value.0, + height: value.1, + origin: Position::ORIGIN, + } + } +} diff --git a/sericom-core/src/ui/terminal.rs b/sericom-core/src/ui/terminal.rs new file mode 100644 index 0000000..f8a4ee5 --- /dev/null +++ b/sericom-core/src/ui/terminal.rs @@ -0,0 +1,57 @@ +use crate::ui::{Buffer, Frame}; + +use super::Rect; + +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct Terminal { + buffers: [Buffer; 2], + current_buffer: usize, + cursor_hidden: bool, + view_area: Rect, +} + +impl Terminal { + pub const fn get_frame(&mut self) -> Frame<'_> { + Frame { + buffer: &mut self.buffers[self.next_buf()], + cursor_position: None, + area: self.view_area, + } + } + + pub fn draw(&mut self, f: F) + where + F: FnOnce(&mut Frame), + { + // Clear next buffer before drawing into + self.buffers[self.next_buf()].reset(); + + let mut frame = self.get_frame(); + f(&mut frame); + } + + const fn next_buf(&self) -> usize { + 1 - self.current_buffer + } + + pub(crate) const fn area(&self) -> Rect { + self.view_area + } +} + +impl Default for Terminal { + fn default() -> Self { + use crossterm::terminal::size; + + let (term_w, term_y) = size().unwrap_or((80, 24)); + let view_area = Rect::from((term_w, term_y)); + let buffers = [Buffer::empty(view_area), Buffer::empty(view_area)]; + + Self { + buffers, + current_buffer: 0_usize, + cursor_hidden: false, + view_area, + } + } +} From 3364891df097f34f8292caea9e514fc8117c16e7 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Wed, 1 Oct 2025 11:35:06 -0500 Subject: [PATCH 02/40] refactor(render): Working on processing parsed sequences to ScreenBuffer changelog: ignore --- sericom-core/src/screen_buffer/mod.rs | 25 +- sericom-core/src/screen_buffer/render.rs | 230 +++++++++---------- sericom-core/src/screen_buffer/ui_command.rs | 62 ++--- sericom-core/src/serial_actor/mod.rs | 1 - sericom-core/src/serial_actor/parser.rs | 1 - sericom-core/src/serial_actor/tasks.rs | 10 +- sericom-core/src/ui/ascii/mod.rs | 3 + sericom-core/src/ui/ascii/process.rs | 161 +++++++++---- sericom-core/src/ui/ascii/test.rs | 131 +++++++++++ sericom-core/src/ui/buffer.rs | 11 +- sericom-core/src/ui/line/color_state.rs | 32 ++- sericom-core/src/ui/line/line.rs | 72 +++--- sericom-core/src/ui/line/span.rs | 71 +++++- sericom-core/src/ui/terminal.rs | 2 +- 14 files changed, 546 insertions(+), 266 deletions(-) delete mode 100644 sericom-core/src/serial_actor/parser.rs create mode 100644 sericom-core/src/ui/ascii/test.rs diff --git a/sericom-core/src/screen_buffer/mod.rs b/sericom-core/src/screen_buffer/mod.rs index f32a352..6bca652 100644 --- a/sericom-core/src/screen_buffer/mod.rs +++ b/sericom-core/src/screen_buffer/mod.rs @@ -37,14 +37,14 @@ pub const MAX_SCROLLBACK: usize = 10000; pub struct ScreenBuffer { /// Scrollback buffer (all lines received from the serial connection). /// Limited by memory. - lines: VecDeque, + pub(crate) lines: VecDeque, /// Current view into the buffer. /// Denotes which line is at the top of the screen. - view_start: usize, + pub(crate) view_start: usize, /// The terminal's dimensions - rect: Rect, + pub(crate) rect: Rect, /// Position of the cursor within the `ScreenBuffer`. - cursor: crate::ui::Position, + pub(crate) cursor: crate::ui::Position, /// Start of text selection. Used for highlighting and copying to clipboard. selection_start: Option<(u16, usize)>, /// End of text selection. Used for highlighting and copying to clipboard. @@ -68,7 +68,7 @@ impl ScreenBuffer { max_scrollback: MAX_SCROLLBACK, }; // Start with an empty line - buffer.lines.push_back(Line::new_default(rect.width.into())); + buffer.lines.push_back(Line::new_empty(rect.width.into())); buffer } @@ -76,6 +76,15 @@ impl ScreenBuffer { self.rect.width } + pub(crate) fn line_from_cursor(&mut self) -> usize { + let line_idx = self.view_start + usize::from(self.cursor.y); + while line_idx > self.lines.len() { + self.lines + .push_back(Line::new_empty(usize::from(self.rect.width))); + } + line_idx + } + fn set_char_at_cursor(&mut self, ch: char) { while usize::from(self.cursor.y) >= self.lines.len() { self.lines @@ -85,13 +94,13 @@ impl ScreenBuffer { if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) && (self.cursor.x as usize) < line.len() { - line.set_char(self.cursor.x as usize, ch); + // line.set_char(self.cursor.x as usize, ch); } } fn clear_from_cursor_to_sol(&mut self) { if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { - line.reset_to(self.cursor.x as usize); + // line.reset_to(self.cursor.x as usize); } } @@ -107,7 +116,7 @@ impl ScreenBuffer { fn clear_from_cursor_to_eol(&mut self) { if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { - line.reset_from(self.cursor.x as usize); + // line.reset_from(self.cursor.x as usize); } } diff --git a/sericom-core/src/screen_buffer/render.rs b/sericom-core/src/screen_buffer/render.rs index b779554..c55ea3b 100644 --- a/sericom-core/src/screen_buffer/render.rs +++ b/sericom-core/src/screen_buffer/render.rs @@ -11,17 +11,6 @@ use crate::{ const MIN_RENDER_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_millis(33); -#[derive(Debug, PartialEq, Eq)] -pub struct TestLine(Vec); -impl TestLine { - fn new() -> Self { - TestLine(vec![]) - } - fn push(&mut self, event: ParserEvent) { - self.0.push(event); - } -} - impl ScreenBuffer { /// Takes incoming data (bytes (`u8`) from a serial connection) and /// processes them accordingly, handling ascii escape sequences, to @@ -32,20 +21,6 @@ impl ScreenBuffer { // self.process_events(writer, event); } - fn add_lines(&mut self, parsed_events: Vec) { - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); - let mut curr_colors: Colors = Colors::new(fg, bg); - let mut curr_line = TestLine::new(); - for event in parsed_events { - if event == ParserEvent::Control(NL) { - self.lines.push_back(curr_line); - } - curr_line.push(event); - } - } - // fn add_char_batch(&mut self, chars: &[char]) { // tracing::debug!("CharBatch: '{:?}'", chars); // while self.cursor.y >= self.lines.len() { @@ -66,17 +41,18 @@ impl ScreenBuffer { /// A helper function to check whether the terminal's screen should be rendered. pub fn should_render_now(&self) -> bool { - use tokio::time::Instant; - - if !self.needs_render { - return false; - } - - let now = Instant::now(); - match self.last_render { - Some(last) => now.duration_since(last) >= MIN_RENDER_INTERVAL, - None => true, - } + // use tokio::time::Instant; + // + // if !self.needs_render { + // return false; + // } + // + // let now = Instant::now(); + // match self.last_render { + // Some(last) => now.duration_since(last) >= MIN_RENDER_INTERVAL, + // None => true, + // } + true } /// Writes the lines/characters received from `add_data` to the terminal's screen. @@ -90,97 +66,97 @@ impl ScreenBuffer { /// that of the cells within the screen that are simply blank. #[allow(clippy::similar_names)] pub fn render(&mut self) -> std::io::Result<()> { - use crossterm::{cursor, queue, style}; - use std::io::{self, Write}; - use tokio::time::Instant; - - if !self.needs_render { - return Ok(()); - } - - let mut writer = BufWriter::new(io::stdout()); - queue!(writer, cursor::Hide)?; - let config = get_config(); - - for screen_y in 0..self.height { - let line_idx = self.view_start + screen_y as usize; - queue!(writer, cursor::MoveTo(0, screen_y))?; - - if let Some(line) = self.lines.get_mut(line_idx) { - let mut current_fg = Color::from(&config.appearance.fg); - let mut current_bg = Color::from(&config.appearance.bg); - queue!( - writer, - style::SetForegroundColor(current_fg), - style::SetBackgroundColor(current_bg) - )?; - - for cell in line { - let global_reverse = self.display_attributes.has(style::Attribute::Reverse); - - let fg = if (cell.is_selected && !global_reverse) - || (!cell.is_selected && global_reverse) - { - cell.bg_color - } else { - cell.fg_color - }; - - let bg = if (cell.is_selected && !global_reverse) - || (!cell.is_selected && global_reverse) - { - cell.fg_color - } else { - cell.bg_color - }; - - if fg != current_fg { - queue!(writer, style::SetForegroundColor(fg))?; - current_fg = fg; - } - if bg != current_bg { - queue!(writer, style::SetBackgroundColor(bg))?; - current_bg = bg; - } - - if self.display_attributes.has(style::Attribute::Bold) { - queue!( - writer, - style::SetAttribute(style::Attribute::Bold), - style::Print(cell.character) - )?; - } else { - queue!(writer, style::Print(cell.character))?; - } - } - } else { - queue!( - writer, - style::ResetColor, - style::Print(" ".repeat(self.width as usize)) - )?; - } - } - - // This is relative the the terminal's L x W, whereas - // self.cursor_pos.y is within the entire line buf - let screen_cursor_y = if self.cursor.y >= self.view_start - && self.cursor.y < self.view_start + self.height as usize - { - (self.cursor.y - self.view_start) as u16 - } else { - self.height - 1 - }; - - queue!( - writer, - cursor::MoveTo(self.cursor.x, screen_cursor_y), - cursor::Show - )?; - writer.flush()?; - - self.last_render = Some(Instant::now()); - self.needs_render = false; + // use crossterm::{cursor, queue, style}; + // use std::io::{self, Write}; + // use tokio::time::Instant; + // + // if !self.needs_render { + // return Ok(()); + // } + // + // let mut writer = BufWriter::new(io::stdout()); + // queue!(writer, cursor::Hide)?; + // let config = get_config(); + // + // for screen_y in 0..self.height { + // let line_idx = self.view_start + screen_y as usize; + // queue!(writer, cursor::MoveTo(0, screen_y))?; + // + // if let Some(line) = self.lines.get_mut(line_idx) { + // let mut current_fg = Color::from(&config.appearance.fg); + // let mut current_bg = Color::from(&config.appearance.bg); + // queue!( + // writer, + // style::SetForegroundColor(current_fg), + // style::SetBackgroundColor(current_bg) + // )?; + // + // for cell in line { + // let global_reverse = self.display_attributes.has(style::Attribute::Reverse); + // + // let fg = if (cell.is_selected && !global_reverse) + // || (!cell.is_selected && global_reverse) + // { + // cell.bg_color + // } else { + // cell.fg_color + // }; + // + // let bg = if (cell.is_selected && !global_reverse) + // || (!cell.is_selected && global_reverse) + // { + // cell.fg_color + // } else { + // cell.bg_color + // }; + // + // if fg != current_fg { + // queue!(writer, style::SetForegroundColor(fg))?; + // current_fg = fg; + // } + // if bg != current_bg { + // queue!(writer, style::SetBackgroundColor(bg))?; + // current_bg = bg; + // } + // + // if self.display_attributes.has(style::Attribute::Bold) { + // queue!( + // writer, + // style::SetAttribute(style::Attribute::Bold), + // style::Print(cell.character) + // )?; + // } else { + // queue!(writer, style::Print(cell.character))?; + // } + // } + // } else { + // queue!( + // writer, + // style::ResetColor, + // style::Print(" ".repeat(self.width as usize)) + // )?; + // } + // } + // + // // This is relative the the terminal's L x W, whereas + // // self.cursor_pos.y is within the entire line buf + // let screen_cursor_y = if self.cursor.y >= self.view_start + // && self.cursor.y < self.view_start + self.height as usize + // { + // (self.cursor.y - self.view_start) as u16 + // } else { + // self.height - 1 + // }; + // + // queue!( + // writer, + // cursor::MoveTo(self.cursor.x, screen_cursor_y), + // cursor::Show + // )?; + // writer.flush()?; + // + // self.last_render = Some(Instant::now()); + // self.needs_render = false; Ok(()) } } diff --git a/sericom-core/src/screen_buffer/ui_command.rs b/sericom-core/src/screen_buffer/ui_command.rs index 61e0e91..ef8f5f4 100644 --- a/sericom-core/src/screen_buffer/ui_command.rs +++ b/sericom-core/src/screen_buffer/ui_command.rs @@ -1,4 +1,4 @@ -use crate::screen_buffer::Position; +use crate::ui::Position; use super::{Cursor, Line, ScreenBuffer}; @@ -46,47 +46,47 @@ impl UIAction for ScreenBuffer { self.view_start = 0; } self.clear_selection(); - self.needs_render = true; + // self.needs_render = true; } /// Called to scroll the terminal down by `lines`. fn scroll_down(&mut self, lines: usize) { - let max_view_start = self.lines.len().saturating_sub(self.height as usize); + let max_view_start = self.lines.len().saturating_sub(self.rect.height as usize); self.view_start = (self.view_start + lines).min(max_view_start); self.clear_selection(); - self.needs_render = true; + // self.needs_render = true; } /// Scrolls to the bottom of the screen. The bottom of the screen is /// the same as the most recent lines received from the serial connection fn scroll_to_bottom(&mut self) { - self.view_start = self.lines.len().saturating_sub(self.height as usize); - self.needs_render = true; + self.view_start = self.lines.len().saturating_sub(self.rect.height as usize); + // self.needs_render = true; } /// Scrolls to the top of the serial connection's history. fn scroll_to_top(&mut self) { self.view_start = 0; - self.needs_render = true; + // self.needs_render = true; } /// Sets the position within the screen for the start of a selection. /// Where `screen_x` is the x-position of the start of the selection, /// and `screen_y` is the y-position (line) of the start of the selection. fn start_selection(&mut self, pos: Position) { - let absolute_line = self.view_start + pos.y; + let absolute_line = self.view_start + usize::from(pos.y); self.clear_selection(); self.selection_start = Some((pos.x, absolute_line)); - self.needs_render = true; + // self.needs_render = true; } /// Update's a selection to include the position passed to it. /// Where `screen_x` is the x-position and `screen_y` is the y-position (line). fn update_selection(&mut self, pos: Position) { - let absolute_line = self.view_start + pos.y; + let absolute_line = self.view_start + usize::from(pos.y); self.selection_end = Some((pos.x, absolute_line)); self.update_selection_highlighting(); - self.needs_render = true; + // self.needs_render = true; } /// Clears the selection state. @@ -96,7 +96,7 @@ impl UIAction for ScreenBuffer { } self.selection_start = None; self.selection_end = None; - self.needs_render = true; + // self.needs_render = true; } /// Copy's the currently selected text to the user's clipboard. @@ -121,17 +121,19 @@ impl UIAction for ScreenBuffer { self.lines.clear(); self.view_start = 0; self.set_cursor_pos((0_u16, 0_usize)); - self.lines.push_back(Line::new_default(self.width.into())); - self.needs_render = true; + self.lines + .push_back(Line::new_default(self.rect.width.into())); + // self.needs_render = true; } /// Clears the current *visible* screen while keeping the buffer's history fn clear_screen(&mut self) { - for _ in 0..self.height { - self.lines.push_back(Line::new_default(self.width.into())); + for _ in 0..self.rect.height { + self.lines + .push_back(Line::new_default(self.rect.width.into())); } - self.view_start = self.lines.len().saturating_sub(self.height as usize); - self.needs_render = true; + self.view_start = self.lines.len().saturating_sub(self.rect.height as usize); + // self.needs_render = true; } } @@ -158,14 +160,14 @@ impl ScreenBuffer { let line_end_x = if line_idx == end_line { end_x } else { - self.width - 1 + self.rect.width - 1 }; - for x in line_start_x..=line_end_x.min(self.width - 1) { - if let Some(cell) = line.get_mut_cell(x as usize) { - cell.is_selected = true; - } - } + // for x in line_start_x..=line_end_x.min(self.width - 1) { + // if let Some(cell) = line.get_mut_cell(x as usize) { + // cell.is_selected = true; + // } + // } } } } @@ -190,14 +192,14 @@ impl ScreenBuffer { let line_end_x = if line_idx == end_line { end_x } else { - self.width - 1 + self.rect.width - 1 }; - for x in line_start_x..=line_end_x.min(self.width - 1) { - if let Some(cell) = line.get_cell(x as usize) { - result.push(cell.character); - } - } + // for x in line_start_x..=line_end_x.min(self.width - 1) { + // if let Some(cell) = line.get_cell(x as usize) { + // result.push(cell.character); + // } + // } if line_idx < end_line { result.push('\n'); diff --git a/sericom-core/src/serial_actor/mod.rs b/sericom-core/src/serial_actor/mod.rs index 4ebf92f..fd83b5f 100644 --- a/sericom-core/src/serial_actor/mod.rs +++ b/sericom-core/src/serial_actor/mod.rs @@ -1,7 +1,6 @@ //! This module holds all of the code directly responsible for interacting //! with the serial connection and tasks within the program. -pub mod parser; pub mod tasks; /// Represents messages/commands that are sent from worker tasks diff --git a/sericom-core/src/serial_actor/parser.rs b/sericom-core/src/serial_actor/parser.rs deleted file mode 100644 index 8b13789..0000000 --- a/sericom-core/src/serial_actor/parser.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sericom-core/src/serial_actor/tasks.rs b/sericom-core/src/serial_actor/tasks.rs index 2853590..cb39666 100644 --- a/sericom-core/src/serial_actor/tasks.rs +++ b/sericom-core/src/serial_actor/tasks.rs @@ -1,7 +1,7 @@ use crate::{ screen_buffer::*, - serial_actor::{SerialEvent, SerialMessage, parser::ByteParser}, - ui::{Rect, Terminal}, + serial_actor::{SerialEvent, SerialMessage}, + ui::{ByteParser, Rect, Terminal}, }; use crossterm::{ event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}, @@ -48,7 +48,7 @@ pub async fn run_stdout_output( data_buffer.extend_from_slice(&data); if data_buffer.len() > 1024 || data.contains(&b'\n') { - screen_buffer.add_data(&data_buffer); + // screen_buffer.add_data(&data_buffer); data_buffer.clear(); if screen_buffer.should_render_now() { screen_buffer.render().ok(); @@ -57,7 +57,7 @@ pub async fn run_stdout_output( render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); } } else if screen_buffer.should_render_now() { - screen_buffer.add_data(&data_buffer); + // screen_buffer.add_data(&data_buffer); data_buffer.clear(); screen_buffer.render().ok(); @@ -68,7 +68,7 @@ pub async fn run_stdout_output( Ok(SerialEvent::Error(e)) => { let error_msg = format!("[ERROR] {e}\r\n"); error!("Added data: {:?}", data_buffer); - screen_buffer.add_data(error_msg.as_bytes()); + // screen_buffer.add_data(error_msg.as_bytes()); screen_buffer.render().ok(); render_timer = None; } diff --git a/sericom-core/src/ui/ascii/mod.rs b/sericom-core/src/ui/ascii/mod.rs index 231060c..9b6d5a5 100644 --- a/sericom-core/src/ui/ascii/mod.rs +++ b/sericom-core/src/ui/ascii/mod.rs @@ -1,6 +1,9 @@ mod parser; pub mod process; +#[cfg(test)] +mod test; + pub(crate) use parser::*; /// Bracket '[' diff --git a/sericom-core/src/ui/ascii/process.rs b/sericom-core/src/ui/ascii/process.rs index f73576c..7b119e8 100644 --- a/sericom-core/src/ui/ascii/process.rs +++ b/sericom-core/src/ui/ascii/process.rs @@ -1,31 +1,40 @@ -use std::io::Write; +use crossterm::style::{Attribute, Attributes, Color}; -use crossterm::{ - cursor, queue, - style::{Attribute, Print, SetAttribute}, - terminal::{Clear, ClearType}, -}; -use miette::IntoDiagnostic; - -use super::*; use crate::{ screen_buffer::ScreenBuffer, - ui::{Cell, Cursor, Line, Span, line::ColorState}, + ui::{ + BK, BS, CR, Cell, Cursor, ESC, FF, Line, NL, ParserEvent, Rect, SEP, Span, TAB, + line::ColorState, + }, }; impl ScreenBuffer { - pub fn process_events(&mut self, events: Vec) -> miette::Result<()> { + pub(crate) fn process_events(&mut self, events: Vec) { let mut color_state = ColorState::default(); + let mut attrs = Attributes::default(); + + let mut curr_line = Line::reserve_new(usize::from(self.rect.width)); + + let span_cap = |line: &Line, rect: &Rect| -> usize { + if !line.is_empty() { + return usize::from(rect.width) - line.num_cells(); + } + usize::from(rect.width) + }; - let mut curr_span = Span::default(); - let mut curr_line = Line::new_default(40); + let mut curr_span = Span::reserve_new(span_cap(&curr_line, &self.rect), None, None); for ev in events { match &ev { ParserEvent::Text(t) => { - t.iter().for_each(|c| { - curr_span.push(Cell::new(char::from(*c))); - }); + for c in t { + let total_cells = curr_line.num_cells() + curr_span.len(); + + // Don't want an else branch because don't want line wrap + if total_cells < usize::from(self.rect.width) { + curr_span.push(Cell::new(char::from(*c))); + } + } } ParserEvent::Control(b) => { match *b { @@ -35,10 +44,19 @@ impl ScreenBuffer { FF => todo!(), // FF => queue!(writer, Clear(ClearType::All)).into_diagnostic()?, NL => { - if !curr_span.is_empty() { - curr_line.push(std::mem::take(&mut curr_span)); + let remainder = usize::from(self.rect.width) + - (curr_span.len() + curr_line.num_cells()); + if remainder != 0 { + curr_span.fill_to_width(span_cap(&curr_line, &self.rect)); + curr_line.push(curr_span); + curr_span = Span::reserve_new( + span_cap(&curr_line, &self.rect), + Some(color_state.get_colors()), + Some(attrs), + ); } - self.push_line(std::mem::take(&mut curr_line)); + self.push_line(curr_line); + curr_line = Line::reserve_new(usize::from(self.rect.width)); } TAB => todo!(), _ => {} @@ -49,36 +67,46 @@ impl ScreenBuffer { if seq.len() >= 3 && seq[0] == ESC && seq[1] == BK - && *seq.last().unwrap() == b'm' + && *seq.last().expect("Verified len != 0") == b'm' { - extract_color_seq(writer, &mut color_state, seq)?; + // Start a new span for change in graphics + if !curr_span.is_empty() { + curr_span.shrink(); + curr_line.push(curr_span); + curr_span = + Span::reserve_new(span_cap(&curr_line, &self.rect), None, None); + } + extract_color_seq(&mut color_state, &mut attrs, seq); + curr_span.set_colors(&color_state); + curr_span.set_attrs(attrs); } } } } - writer.flush().ok(); - Ok(()) } } -fn extract_color_seq( - writer: &mut W, - color_state: &mut ColorState, - seq: &[u8], -) -> miette::Result<()> { +fn extract_color_seq(color_state: &mut ColorState, attrs: &mut Attributes, seq: &[u8]) { // Get the part between 'ESC[' and 'm' let body = &seq[2..seq.len() - 1]; + eprintln!("BODY: {}", String::from_utf8_lossy(body)); // If none, it is just setting a graphics mode let Some(first_part_idx) = body.iter().position(|&b| b == SEP) else { - process_graphics_mode(writer, color_state, body)?; - return Ok(()); + process_graphics_mode(color_state, attrs, body); + eprintln!("SHORT-CIRCUIT-EXTRACTED: {}", String::from_utf8_lossy(body)); + return; }; let (mode, color_bytes) = body.split_at(first_part_idx); + eprintln!( + "MODE: {}, COLOR_BYTES: {}", + String::from_utf8_lossy(mode), + String::from_utf8_lossy(color_bytes) + ); // If true, color_str skips the graphics_mode part - let color_str = if process_graphics_mode(writer, color_state, mode)? { + let color_str = if process_graphics_mode(color_state, attrs, mode) { &color_bytes[1..] } else { body @@ -87,15 +115,13 @@ fn extract_color_seq( if let Ok(color_str) = str::from_utf8(color_str) { color_state.set_colors(color_str); } - - Ok(()) } -fn process_graphics_mode( - writer: &mut W, +fn process_graphics_mode( color_state: &mut ColorState, + attrs: &mut Attributes, body: &[u8], -) -> miette::Result { +) -> bool { let attr = match *body { [b'0'] => { *color_state = ColorState::default(); @@ -117,20 +143,63 @@ fn process_graphics_mode( [b'2', b'8'] => Attribute::NoHidden, [b'2', b'9'] => Attribute::NotCrossedOut, _ => { - return Ok(false); + // try parsing foreground colors i.e. ESC[34m + // if let Ok(ascii_str) = str::from_utf8(body) { + // eprintln!("ASCII_STR: {}", ascii_str); + // color_state.set_colors(ascii_str); + // } + // handle colors like "30".."37", "40".."47", "90".."97", "100".."107" + if let Ok(s) = std::str::from_utf8(body) { + if let Ok(num) = s.parse::() { + match num { + // basic FG + 30 => color_state.set_fg(Color::Black), + 31 => color_state.set_fg(Color::DarkRed), + 32 => color_state.set_fg(Color::DarkGreen), + 33 => color_state.set_fg(Color::DarkYellow), + 34 => color_state.set_fg(Color::DarkBlue), + 35 => color_state.set_fg(Color::DarkMagenta), + 36 => color_state.set_fg(Color::DarkCyan), + 37 => color_state.set_fg(Color::Grey), + // basic BG + 40 => color_state.set_bg(Color::Black), + 41 => color_state.set_bg(Color::DarkRed), + 42 => color_state.set_bg(Color::DarkGreen), + 43 => color_state.set_bg(Color::DarkYellow), + 44 => color_state.set_bg(Color::DarkBlue), + 45 => color_state.set_bg(Color::DarkMagenta), + 46 => color_state.set_bg(Color::DarkCyan), + 47 => color_state.set_bg(Color::Grey), + // bright FG + 90 => color_state.set_fg(Color::DarkGrey), + 91 => color_state.set_fg(Color::Red), + 92 => color_state.set_fg(Color::Green), + 93 => color_state.set_fg(Color::Yellow), + 94 => color_state.set_fg(Color::Blue), + 95 => color_state.set_fg(Color::Magenta), + 96 => color_state.set_fg(Color::Cyan), + 97 => color_state.set_fg(Color::White), + // bright BG + 100 => color_state.set_bg(Color::DarkGrey), + 101 => color_state.set_bg(Color::Red), + 102 => color_state.set_bg(Color::Green), + 103 => color_state.set_bg(Color::Yellow), + 104 => color_state.set_bg(Color::Blue), + 105 => color_state.set_bg(Color::Magenta), + 106 => color_state.set_bg(Color::Cyan), + 107 => color_state.set_bg(Color::White), + _ => {} + } + } + } + return false; } // ignore the rest }; if *body == [b'2', b'2'] { - queue!( - writer, - SetAttribute(attr), - SetAttribute(Attribute::NormalIntensity) - ) - .into_diagnostic()?; - } else { - queue!(writer, SetAttribute(attr)).into_diagnostic()?; + attrs.set(Attribute::NormalIntensity); } - Ok(true) + attrs.set(attr); + true } // pub fn process_events( // writer: &mut W, diff --git a/sericom-core/src/ui/ascii/test.rs b/sericom-core/src/ui/ascii/test.rs new file mode 100644 index 0000000..43e1f0b --- /dev/null +++ b/sericom-core/src/ui/ascii/test.rs @@ -0,0 +1,131 @@ +use std::collections::VecDeque; + +use crossterm::style::{Attributes, Colors}; + +use super::*; +use crate::{ + configs::{ConfigOverride, initialize_config}, + screen_buffer::*, + ui::{Cell, Line, Position, Rect, Span}, +}; +const CONFIG_OVERRIDE: ConfigOverride = ConfigOverride { + color: None, + out_dir: None, + exit_script: None, +}; +const TERMINAL_SIZE: (u16, u16) = (80, 24); + +// #[test] +// fn test_helloworld_parsing() { +// initialize_config(CONFIG_OVERRIDE).ok(); +// let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); +// let mut sb = ScreenBuffer::new(rect); +// let mut parser = ByteParser::new(); +// // let parsed = parser.feed(b"Hello\nWorld\n"); +// let parsed = parser.feed(TEST_BYTES); +// +// eprintln!("{parsed:#?}"); +// +// sb.process_events(parsed); +// +// eprintln!("{}", debug_dump(&sb)); +// assert_eq!(1, 2); +// } + +#[test] +fn test_color_spans() { + initialize_config(CONFIG_OVERRIDE).ok(); + let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); + let mut sb = ScreenBuffer::new(rect); + let mut parser = ByteParser::new(); + let parsed = parser.feed(b"\x1b[31mRed\x1b[34mBlue\n"); + + // eprintln!("{parsed:#?}"); + + sb.process_events(parsed); + // eprintln!("{}", debug_dump(&sb.lines)); + + let mut vecd = VecDeque::new(); + vecd.push_back(Line::new_empty(TERMINAL_SIZE.0.into())); + let mut new_line = Line::reserve_new(2); + + let mut red_span = Span::reserve_new( + 3, + Some(Colors::new( + crossterm::style::Color::DarkRed, + crossterm::style::Color::Reset, + )), + Some(Attributes::default()), + ); + "Red".chars().for_each(|c| red_span.push(Cell::from(c))); + new_line.push(red_span); + + let mut blue_span = Span::reserve_new( + (TERMINAL_SIZE.0 - 3).into(), + Some(Colors::new( + crossterm::style::Color::DarkBlue, + crossterm::style::Color::Reset, + )), + Some(Attributes::default()), + ); + "Blue".chars().for_each(|c| blue_span.push(Cell::from(c))); + blue_span.fill_to_width((TERMINAL_SIZE.0 - 3).into()); + new_line.push(blue_span); + vecd.push_back(new_line); + + // eprintln!("{}", debug_dump(&vecd)); + + assert_eq!(vecd, sb.lines); +} + +fn debug_dump(lines: &VecDeque) -> String { + let mut out = String::new(); + for (i, line) in lines.iter().enumerate() { + use std::fmt::Write; + + writeln!(&mut out, "Line {i}:").unwrap(); + + for (j, span) in line.iter().enumerate() { + let text: String = span.iter().map(|c| c.character).collect(); + writeln!( + &mut out, + " Span {}: \"{}\" (len = {}, attrs = {:?}, colors = {:?})", + j, + text, + span.len(), + span.attrs, + span.colors + ) + .unwrap(); + } + } + out +} + +const TEST_BYTES: &[u8] = &[ + 8, 8, 8, 8, 8, 8, 8, 8, 8, 32, 32, 32, 32, 32, 32, 32, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 77, 111, + 116, 104, 101, 114, 98, 111, 97, 114, 100, 32, 97, 115, 115, 101, 109, 98, 108, 121, 32, 110, + 117, 109, 98, 101, 114, 32, 32, 32, 32, 32, 58, 32, 55, 51, 45, 49, 54, 54, 56, 54, 45, 48, 53, + 13, 10, 32, 45, 45, 77, 111, 114, 101, 45, 45, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 32, 32, 32, 32, + 32, 32, 32, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 13, 10, 83, 119, 105, 116, 99, 104, 62, 13, 10, 83, + 119, 105, 116, 99, 104, 62, 13, 10, 83, 119, 105, 116, 99, 104, 62, 115, 104, 32, 118, 101, + 114, 13, 10, 67, 105, 115, 99, 111, 32, 73, 79, 83, 32, 83, 111, 102, 116, 119, 97, 114, 101, + 44, 32, 67, 50, 57, 54, 48, 88, 32, 83, 111, 102, 116, 119, 97, 114, 101, 32, 40, 67, 50, 57, + 54, 48, 88, 45, 85, 78, 73, 86, 69, 82, 83, 65, 76, 75, 57, 45, 77, 41, 44, 32, 86, 101, 114, + 115, 105, 111, 110, 32, 49, 53, 46, 50, 40, 50, 41, 69, 53, 44, 32, 82, 69, 76, 69, 65, 83, 69, + 32, 83, 79, 70, 84, 87, 65, 82, 69, 32, 40, 102, 99, 50, 41, 13, 10, 84, 101, 99, 104, 110, + 105, 99, 97, 108, 32, 83, 117, 112, 112, 111, 114, 116, 58, 32, 104, 116, 116, 112, 58, 47, 47, + 119, 119, 119, 46, 99, 105, 115, 99, 111, 46, 99, 111, 109, 47, 116, 101, 99, 104, 115, 117, + 112, 112, 111, 114, 116, 13, 10, 67, 111, 112, 121, 114, 105, 103, 104, 116, 32, 40, 99, 41, + 32, 49, 57, 56, 54, 45, 50, 48, 49, 54, 32, 98, 121, 32, 67, 105, 115, 99, 111, 32, 83, 121, + 115, 116, 101, 109, 115, 44, 32, 73, 110, 99, 46, 13, 10, 67, 111, 109, 112, 105, 108, 101, + 100, 32, 84, 104, 117, 32, 48, 50, 45, 74, 117, 110, 45, 49, 54, 32, 48, 49, 58, 51, 49, 32, + 98, 121, 32, 112, 114, 111, 100, 95, 114, 101, 108, 95, 116, 101, 97, 109, 13, 10, 13, 10, 82, + 79, 77, 58, 32, 66, 111, 111, 116, 115, 116, 114, 97, 112, 32, 112, 114, 111, 103, 114, 97, + 109, 32, 105, 115, 32, 67, 50, 57, 54, 48, 88, 32, 98, 111, 111, 116, 32, 108, 111, 97, 100, + 101, 114, 13, 10, 66, 79, 79, 84, 76, 68, 82, 58, 32, 67, 50, 57, 54, 48, 88, 32, +]; + +/* +66, 111, 111, 116, 32, 76, 111, 97, 100, 101, 114, 32, 40, 67, 50, 57, 54, 48, 88, 45, 72, 66, 79, 79, 84, 45, 77, 41, 32, 86, 101, 114, 115, 105, 111, 110, 32, 49, 53, 46, 50, 40, 51, 114, 41, 69, 49, 44, 32, 82, 69, 76, 69, 65, 83, 69, 32, 83, 79, 70, 84, 87, 65, 82, 69, 32, 40, 102, 99, 49, 41, 13, 10, 13, 10, 83, 119, 105, 116, 99, 104, 32, 117, 112, 116, 105, 109, 101, 32, 105, 115, 32, 51, 32, 100, 97, 121, 115, 44, 32, 50, 32, 104, 111, 117, 114, 115, 44, 32, 49, 54, 32, 109, 105, 110, 117, 116, 101, 115, 13, 10, 83, 121, 115, 116, 101, 109, 32, 114, 101, 116, 117, 114, 110, 101, 100, 32, 116, 111, 32, 82, 79, 77, 32, 98, 121, 32, 112, 111, 119, 101, 114, 45, 111, 110, 13, 10, 83, 121, 115, 116, 101, 109, 32, 114, 101, 115, 116, 97, 114, 116, 101, 100, 32, 97, 116, 32, 48, 49, 58, 52, 55, 58, 51, 55, 32, 85, 84, 67, 32, 83, 117, 110, 32, 83, 101, 112, 32, 50, 56, 32, 50, 48, 50, 53, 13, 10, 83, 121, 115, 116, 101, 109, 32, 105, 109, 97, 103, 101, 32, 102, 105, 108, 101, 32, 105, 115, 32, 34, 102, 108, 97, 115, 104, 58, 47, 99, 50, 57, 54, 48, 120, 45, 117, 110, 105, 118, 101, 114, 115, 97, 108, 107, 57, 45, 109, 122, 46, 49, 53, 50, 45, 50, 46, 69, 53, 47, 99, 50, 57, 54, 48, 120, 45, 117, 110, 105, 118, 101, 114, 115, 97, 108, 107, 57, 45, 109, 122, 46, 49, 53, 50, 45, 50, 46, 69, 53, 46, 98, 105, 110, 34, 13, 10, 76, 97, 115, 116, 32, 114, 101, 108, 111, 97, 100, 32, 114, 101, 97, 115, 111, 110, 58, 32, 112, 111, 119, 101, 114, 45, 111, 110, 13, 10, 13, 10, 13, 10, 13, 10, 84, 104, 105, 115, 32, 112, 114, 111, 100, 117, 99, 116, 32, 99, 111, 110, 116, 97, 105, 110, 115, 32, 99, 114, 121, 112, 116, 111, 103, 114, 97, 112, 104, 105, 99, 32, 102, 101, 97, 116, 117, 114, 101, 115, 32, 97, 110, 100, 32, 105, 115, 32, 115, 117, 98, 106, 101, 99, 116, 32, 116, 111, 32, 85, 110, 105, 116, 101, 100, 13, 10, 83, 116, 97, 116, 101, 115, 32, 97, 110, 100, 32, 108, 111, 99, 97, 108, 32, 99, 111, 117, 110, 116, 114, 121, 32, 108, 97, 119, 115, 32, 103, 111, 118, 101, 114, 110, 105, 110, 103, 32, 105, 109, 112, 111, 114, 116, 44, 32, 101, 120, 112, 111, 114, 116, 44, 32, 116, 114, 97, 110, 115, 102, 101, 114, 32, 97, 110, 100, 13, 10, 117, 115, 101, 46, 32, 68, 101, 108, 105, 118, 101, 114, 121, 32, 111, 102, 32, 67, 105, 115, 99, 111, 32, 99, 114, 121, 112, 116, 111, 103, 114, 97, 112, 104, 105, 99, 32, 112, 114, 111, 100, 117, 99, 116, 115, 32, 100, 111, 101, 115, 32, 110, 111, 116, 32, 105, 109, 112, 108, 121, 13, 10, 116, 104, 105, 114, 100, 45, 112, 97, 114, 116, 121, 32, 97, 117, 116, 104, 111, 114, 105, 116, 121, 32, 116, 111, 32, 105, 109, 112, 111, 114, 116, 44, 32, 101, 120, 112, 111, 114, 116, 44, 32, 100, 105, 115, 116, 114, 105, 98, 117, 116, 101, 32, 111, 114, 32, 117, 115, 101, 32, 101, 110, 99, 114, 121, 112, 116, 105, 111, 110, 46, 13, 10, 32, 45, 45, 77, 111, 114, 101, 45, 45, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 32, 32, 32, 32, 32, 32, 32, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 73, 109, 112, 111, 114, 116, 101, 114, 115, 44, 32, 101, 120, 112, 111, 114, 116, 101, 114, 115, 44, 32, 100, 105, 115, 116, 114, 105, 98, 117, 116, 111, 114, 115, 32, 97, 110, 100, 32, 117, 115, 101, 114, 115, 32, 97, 114, 101, 32, 114, 101, 115, 112, 111, 110, 115, 105, 98, 108, 101, 32, 102, 111, 114, 13, 10, 99, 111, 109, 112, 108, 105, 97, 110, 99, 101, 32, 119, 105, 116, 104, 32, 85, 46, 83, 46, 32, 97, 110, 100, 32, 108, 111, 99, 97, 108, 32, 99, 111, 117, 110, 116, 114, 121, 32, 108, 97, 119, 115, 46, 32, 66, 121, 32, 117, 115, 105, 110, 103, 32, 116, 104, 105, 115, 32, 112, 114, 111, 100, 117, 99, 116, 32, 121, 111, 117, 13, 10, 97, 103, 114, 101, 101, 32, 116, 111, 32, 99, 111, 109, 112, 108, 121, 32, 119, 105, 116, 104, 32, 97, 112, 112, 108, 105, 99, 97, 98, 108, 101, 32, 108, 97, 119, 115, 32, 97, 110, 100, 32, 114, 101, 103, 117, 108, 97, 116, 105, 111, 110, 115, 46, 32, 73, 102, 32, 121, 111, 117, 32, 97, 114, 101, 32, 117, 110, 97, 98, 108, 101, 13, 10, 116, 111, 32, 99, 111, 109, 112, 108, 121, 32, 119, 105, 116, 104, 32, 85, 46, 83, 46, 32, 97, 110, 100, 32, 108, 111, 99, 97, 108, 32, 108, 97, 119, 115, 44, 32, 114, 101, 116, 117, 114, 110, 32, 116, 104, 105, 115, 32, 112, 114, 111, 100, 117, 99, 116, 32, 105, 109, 109, 101, 100, 105, 97, 116, 101, 108, 121, 46, 13, 10, 13, 10, 65, 32, 115, 117, 109, 109, 97, 114, 121, 32, 111, 102, 32, 85, 46, 83, 46, 32, 108, 97, 119, 115, 32, 103, 111, 118, 101, 114, 110, 105, 110, 103, 32, 67, 105, 115, 99, 111, 32, 99, 114, 121, 112, 116, 111, 103, 114, 97, 112, 104, 105, 99, 32, 112, 114, 111, 100, 117, 99, 116, 115, 32, 109, 97, 121, 32, 98, 101, 32, 102, 111, 117, 110, 100, 32, 97, 116, 58, 13, 10, 104, 116, 116, 112, 58, 47, 47, 119, 119, 119, 46, 99, 105, 115, 99, 111, 46, 99, 111, 109, 47, 119, 119, 108, 47, 101, 120, 112, 111, 114, 116, 47, 99, 114, 121, 112, 116, 111, 47, 116, 111, 111, 108, 47, 115, 116, 113, 114, 103, 46, 104, 116, 109, 108, 13, 10, 13, 10, 73, 102, 32, 121, 111, 117, 32, 114, 101, 113, 117, 105, 114, 101, 32, 102, 117, 114, 116, 104, 101, 114, 32, 97, 115, 115, 105, 115, 116, 97, 110, 99, 101, 32, 112, 108, 101, 97, 115, 101, 32, 99, 111, 110, 116, 97, 99, 116, 32, 117, 115, 32, 98, 121, 32, 115, 101, 110, 100, 105, 110, 103, 32, 101, 109, 97, 105, 108, 32, 116, 111, 13, 10, 101, 120, 112, 111, 114, 116, 64, 99, 105, 115, 99, 111, 46, 99, 111, 109, 46, 13, 10, 13, 10, 99, 105, 115, 99, 111, 32, 87, 83, 45, 67, 50, 57, 54, 48, 88, 45, 52, 56, 70, 80, 68, 45, 76, 32, 40, 65, 80, 77, 56, 54, 88, 88, 88, 41, 32, 112, 114, 111, 99, 101, 115, 115, 111, 114, 32, 40, 114, 101, 118, 105, 115, 105, 111, 110, 32, 77, 48, 41, 32, 119, 105, 116, 104, 32, 53, 50, 52, 50, 56, 56, 75, 32, 98, 121, 116, 101, 115, 32, 111, 102, 32, 109, 101, 109, 111, 114, 121, 46, 13, 10, 80, 114, 111, 99, 101, 115, 115, 111, 114, 32, 98, 111, 97, 114, 100, 32, 73, 68, 32, 70, 79, 67, 50, 48, 50, 55, 87, 49, 75, 66, 13, 10, 76, 97, 115, 116, 32, 114, 101, 115, 101, 116, 32, 102, 114, 111, 109, 32, 112, 111, 119, 101, 114, 45, 111, 110, 13, 10, 49, 32, 86, 105, 114, 116, 117, 97, 108, 32, 69, 116, 104, 101, 114, 110, 101, 116, 32, 105, 110, 116, 101, 114, 102, 97, 99, 101, 13, 10, 49, 32, 70, 97, 115, 116, 69, 116, 104, 101, 114, 110, 101, 116, 32, 105, 110, 116, 101, 114, 102, 97, 99, 101, 13, 10, 53, 48, 32, 71, 105, 103, 97, 98, 105, 116, 32, 69, 116, 104, 101, 114, 110, 101, 116, 32, 105, 110, 116, 101, 114, 102, 97, 99, 101, 115, 13, 10, 50, 32, 84, 101, 110, 32, 71, 105, 103, 97, 98, 105, 116, 32, 69, 116, 104, 101, 114, 110, 101, 116, 32, 105, 110, 116, 101, 114, 102, 97, 99, 101, 115, 13, 10, 84, 104, 101, 32, 112, 97, 115, 115, 119, 111, 114, 100, 45, 114, 101, 99, 111, 118, 101, 114, 121, 32, 109, 101, 99, 104, 97, 110, 105, 115, 109, 32, 105, 115, 32, 101, 110, 97, 98, 108, 101, 100, 46, 13, 10, 13, 10, 53, 49, 50, 75, 32, 98, 121, 116, 101, 115, 32, 111, 102, 32, 102, 108, 97, 115, 104, 45, 115, 105, 109, 117, 108, 97, 116, 101, 100, 32, 110, 111, 110, 45, 118, 111, 108, 97, 116, 105, 108, 101, 32, 99, 111, 110, 102, 105, 103, 117, 114, 97, 116, 105, 111, 110, 32, 109, 101, 109, 111, 114, 121, 46, 13, 10, 66, 97, 115, 101, 32, 101, 116, 104, 101, 114, 110, 101, 116, 32, 77, 65, 67, 32, 65, 100, 100, 114, 101, 115, 115, 32, 32, 32, 32, 32, 32, 32, 58, 32, 48, 48, 58, 65, 50, 58, 56, 57, 58, 66, 54, 58, 51, 70, 58, 48, 48, 13, 10, 32, 45, 45, 77, 111, 114, 101, 45, 45, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 32, 32, 32, 32, 32, 32, 32, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 13, 10, 83, 119, 105, 116, 99, 104, 62, 13, 10, 83, 119, 105, 116, 99, 104, 62]; +*/ diff --git a/sericom-core/src/ui/buffer.rs b/sericom-core/src/ui/buffer.rs index 590de57..96646f2 100644 --- a/sericom-core/src/ui/buffer.rs +++ b/sericom-core/src/ui/buffer.rs @@ -1,9 +1,10 @@ #![allow(unused)] use super::Line; use super::Rect; -use crate::screen_buffer::Cell; +use crate::ui::Cell; +use crate::ui::Span; -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct Buffer { area: Rect, content: Vec, @@ -16,12 +17,12 @@ impl Buffer { } #[must_use] pub fn empty(area: Rect) -> Self { - Self::filled(area, Cell::EMPTY) + Self::filled(area, Span::default()) } #[must_use] - pub fn filled(area: Rect, cell: Cell) -> Self { - let line = Line::new(area.width.into(), cell); + pub fn filled(area: Rect, span: Span) -> Self { + let line = Line::new(area.width.into(), span); let size = area.height as usize; let content = vec![line; size]; Self { area, content } diff --git a/sericom-core/src/ui/line/color_state.rs b/sericom-core/src/ui/line/color_state.rs index 1577ce9..9e7db87 100644 --- a/sericom-core/src/ui/line/color_state.rs +++ b/sericom-core/src/ui/line/color_state.rs @@ -5,14 +5,20 @@ use crossterm::{ use miette::IntoDiagnostic; use std::io::Write; +use crate::configs::get_config; + +#[derive(Debug)] pub struct ColorState { colors: Colors, } impl Default for ColorState { fn default() -> Self { + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); Self { - colors: Colors::new(Color::Green, Color::Reset), + colors: Colors::new(fg, bg), } } } @@ -25,11 +31,29 @@ impl ColorState { queue!(writer, SetColors(self.colors), Print(text)).into_diagnostic()?; Ok(()) } - + pub fn set_fg(&mut self, color: Color) { + self.colors = Colors::new(color, self.colors.background.unwrap_or(Color::Reset)); + } + pub fn set_bg(&mut self, color: Color) { + self.colors = Colors::new(self.colors.foreground.unwrap_or(Color::Reset), color); + } + pub fn reset(&mut self) { + self.colors = Colors::new(Color::Reset, Color::Reset); + } pub fn set_colors(&mut self, ascii_str: &str) { self.colors = match Colored::parse_ansi(ascii_str) { - Some(colored) => self.colors.then(&colored.into()), - None => self.colors, + Some(colored) => { + eprintln!("{:#?}", colored); + self.colors.then(&colored.into()) + } + None => { + match Color::parse_ansi(ascii_str) { + Some(color) => eprintln!("Second try got: {:#?}", color), + None => eprintln!("Failed to parse ascii_str second time"), + } + eprintln!("Failed to parse ascii_str"); + self.colors + } }; } } diff --git a/sericom-core/src/ui/line/line.rs b/sericom-core/src/ui/line/line.rs index 0611216..ad23b08 100644 --- a/sericom-core/src/ui/line/line.rs +++ b/sericom-core/src/ui/line/line.rs @@ -23,40 +23,54 @@ impl Line { Self(vec![Span::default(); width]) } - /// Create a new line with the length/size of `width`. + /// Create a new line with a single [`Span`] with the length/size of `width`. /// - /// Filled with [`Cell::EMPTY`]. + /// Filled with [`Span::EMPTY`]. #[must_use] pub fn new_empty(width: usize) -> Self { - Self(vec![Span::default(); width]) + Self(vec![Span::new_empty(width); 1]) } - /// Iterates over all the [`Cell`]s within the line and sets them to [`Cell::default()`]. - pub fn reset(&mut self) { - self.0.iter_mut().for_each(|cell| *cell = Cell::default()); - } - - /// Iterates over the [`Cell`]s to index `idx` within [`Self`] - /// and sets them to [`Cell::default()`]. - pub fn reset_to(&mut self, idx: usize) { - self.0[..idx] - .iter_mut() - .for_each(|cell| *cell = Cell::default()); + /// Create a new line with a single [`Span`] reserved with a capacity of + /// `width`, does not fill [`Self`] with any [`Cell`]s, just reserves space. + /// + /// [`Cell`]: crate::ui::Cell + #[must_use] + pub fn reserve_new(spans: usize) -> Self { + Self(Vec::with_capacity(spans)) } + // pub fn reserve_new(width: usize) -> Self { + // Self(vec![Span::reserve_new(width); 1]) + // } - /// Iterates over the [`Cell`]s from index `idx` within [`Self`] - /// to the end of [`Self`] and sets them to [`Cell::default()`]. - pub fn reset_from(&mut self, idx: usize) { - self.0 - .iter_mut() - .skip(idx) - .for_each(|cell| *cell = Cell::default()); + /// Iterates over all the [`Cell`]s within the line and sets them to [`Cell::default()`]. + pub fn reset(&mut self) { + self.0.iter_mut().for_each(|span| { + span.reset(); + }); } - /// Sets the character in [`Cell`] at [`Self`]\[`idx`\] to `ch`. - pub fn set_char(&mut self, idx: usize, ch: char) { - self.0[idx].character = ch; - } + // /// Iterates over the [`Cell`]s to index `idx` within [`Self`] + // /// and sets them to [`Cell::default()`]. + // pub fn reset_to(&mut self, idx: usize) { + // self.0[..idx] + // .iter_mut() + // .for_each(|cell| *cell = Cell::default()); + // } + + // /// Iterates over the [`Cell`]s from index `idx` within [`Self`] + // /// to the end of [`Self`] and sets them to [`Cell::default()`]. + // pub fn reset_from(&mut self, idx: usize) { + // self.0 + // .iter_mut() + // .skip(idx) + // .for_each(|cell| *cell = Cell::default()); + // } + // + // /// Sets the character in [`Cell`] at [`Self`]\[`idx`\] to `ch`. + // pub fn set_char(&mut self, idx: usize, ch: char) { + // self.0[idx].character = ch; + // } /// Util function to return the length of [`Self`]. #[must_use] @@ -83,14 +97,18 @@ impl Line { self.0.get_mut(idx) } - pub fn count_cells(&self) -> usize { + pub fn num_cells(&self) -> usize { let mut num_cells = 0; self.0.iter().for_each(|span| { - num_cells += span.count(); + num_cells += span.len(); }); num_cells } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn iter(&self) -> std::slice::Iter<'_, Span> { self.0.iter() } diff --git a/sericom-core/src/ui/line/span.rs b/sericom-core/src/ui/line/span.rs index c476fef..45b9fe2 100644 --- a/sericom-core/src/ui/line/span.rs +++ b/sericom-core/src/ui/line/span.rs @@ -1,6 +1,6 @@ use std::ops::{Index, IndexMut}; -use crossterm::style::{Attributes, Color, Colors}; +use crossterm::style::{Attribute, Attributes, Color, Colors}; use crate::{ configs::get_config, @@ -9,30 +9,80 @@ use crate::{ #[derive(Debug, Clone, Eq, PartialEq)] pub struct Span { - cells: Vec, - attrs: Attributes, - colors: Colors, + pub(crate) cells: Vec, + pub(crate) attrs: Attributes, + pub(crate) colors: Colors, } impl Default for Span { fn default() -> Self { - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); + let colors = Self::get_config_colors(); Self { cells: Vec::default(), attrs: Attributes::default(), - colors: Colors::new(fg, bg), + colors, } } } impl Span { + fn get_config_colors() -> Colors { + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + Colors::new(fg, bg) + } + pub(crate) fn set_attrs(&mut self, attrs: Attributes) { + self.attrs = attrs; + } + pub(crate) fn add_attr(&mut self, attr: Attribute) { + self.attrs.set(attr); + } + pub(crate) fn reset(&mut self) { + self.cells.iter_mut().for_each(|cell| { + *cell = Cell::EMPTY; + }); + self.attrs = Attributes::default(); + self.colors = Self::get_config_colors(); + } + pub(crate) fn new_empty(width: usize) -> Self { + let colors = Self::get_config_colors(); + Self { + cells: vec![Cell::EMPTY; width], + attrs: Attributes::default(), + colors, + } + } + /// Creates a new [`Span`] and reserves space for `width` of [`Cell`]s. + /// + /// This calls [`Vec::with_capacity()`] and does not create any [`Cell`]s. + /// Create the [`Span`] with `colors` and/or `attrs`. If `None`, uses colors + /// from config file ([`get_config()`]) and [`Attributes::default()`]. + pub(crate) fn reserve_new( + width: usize, + colors: Option, + attrs: Option, + ) -> Self { + let colors = colors.unwrap_or_else(Self::get_config_colors); + let attrs = attrs.unwrap_or_default(); + Self { + cells: Vec::with_capacity(width), + attrs, + colors, + } + } + pub(crate) fn fill_to_width(&mut self, width: usize) { + self.cells.resize(width, Cell::EMPTY); + } + pub(crate) fn shrink(&mut self) { + let size = self.cells.len(); + self.cells.shrink_to(size); + } pub(crate) fn push(&mut self, cell: Cell) { self.cells.push(cell); } - pub(crate) fn count(&self) -> usize { - self.cells.iter().count() + pub(crate) fn len(&self) -> usize { + self.cells.len() } pub(crate) fn set_colors(&mut self, colors: &ColorState) { self.colors = colors.get_colors(); @@ -43,7 +93,6 @@ impl Span { pub fn iter(&self) -> std::slice::Iter<'_, Cell> { self.cells.iter() } - pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Cell> { self.cells.iter_mut() } diff --git a/sericom-core/src/ui/terminal.rs b/sericom-core/src/ui/terminal.rs index f8a4ee5..cad67ff 100644 --- a/sericom-core/src/ui/terminal.rs +++ b/sericom-core/src/ui/terminal.rs @@ -2,7 +2,7 @@ use crate::ui::{Buffer, Frame}; use super::Rect; -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq)] pub struct Terminal { buffers: [Buffer; 2], current_buffer: usize, From a04b22ffdb1c284f32f538d68fe8d654876f402e Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Thu, 2 Oct 2025 03:02:48 -0500 Subject: [PATCH 03/40] refactor(render): Color parsing, line/span splitting good checkpoint, stuff is pretty much solid regarding color parsing from escape sequences and building lines/spans from the parsed. Only major changes would come from needing to work with cursor movements. TODO - handle cursor movement. changelog: ignore --- justfile | 3 + sericom-core/src/ui/ascii/colors.rs | 488 +++++++++++++++++++++++++++ sericom-core/src/ui/ascii/mod.rs | 4 +- sericom-core/src/ui/ascii/process.rs | 166 +-------- sericom-core/src/ui/ascii/test.rs | 339 ++++++++++++++----- 5 files changed, 752 insertions(+), 248 deletions(-) create mode 100644 sericom-core/src/ui/ascii/colors.rs diff --git a/justfile b/justfile index 9d8b9f2..e1af28b 100644 --- a/justfile +++ b/justfile @@ -9,3 +9,6 @@ run-trace: check-win: cargo c --target x86_64-pc-windows-msvc + +test: + cargo t -p sericom-core --lib diff --git a/sericom-core/src/ui/ascii/colors.rs b/sericom-core/src/ui/ascii/colors.rs new file mode 100644 index 0000000..c33c6c0 --- /dev/null +++ b/sericom-core/src/ui/ascii/colors.rs @@ -0,0 +1,488 @@ +use crossterm::style::{Attribute, Attributes, Color}; + +use crate::ui::{BK, ColorState, ESC, SEP}; + +pub fn is_graphics_seq(seq: &[u8]) -> bool { + seq.len() >= 3 + && seq[0] == ESC + && seq[1] == BK + && *seq.last().expect("Verified len != 0") == b'm' +} + +pub fn process_colors(seq: &[u8], color_state: &mut ColorState, attrs: &mut Attributes) { + // Get the part between 'ESC[' and 'm' + let body = &seq[2..seq.len() - 1]; + let mut body_idx = 0; + let mut parts_iter = body.split(|&p| p == SEP).peekable(); + + while let Some(part) = parts_iter.next() { + match part { + // Give any 38;5;26 / 48;2;50;60;70 sequences to crossterm + [b'3' | b'4', b'8'] => { + if let Some(ident) = parts_iter.next_if(|n| *n == [b'5']) { + let Some(color) = parts_iter.next() else { + break; + }; + + // Get slice from body (+ 1 for the separators ';') + let part_str_len = part.len() + 1 + ident.len() + 1 + color.len(); + let slice = &body[body_idx..body_idx + part_str_len]; + + #[cfg(test)] + eprintln!("slice: {}", str::from_utf8(slice).unwrap()); + + if let Ok(s) = std::str::from_utf8(slice) { + color_state.set_colors(s); + } + + // add to body_idx the indexes we consumed + body_idx += part_str_len - part.len(); + } else if let Some(ident) = parts_iter.next_if(|n| *n == [b'2']) { + let Some(r) = parts_iter.next() else { break }; + let Some(g) = parts_iter.next() else { break }; + let Some(b) = parts_iter.next() else { break }; + + // get slice from body (+ 1 for the separators ';') + let part_str_len = + part.len() + 1 + ident.len() + 1 + r.len() + 1 + g.len() + 1 + b.len(); + let slice = &body[body_idx..body_idx + part_str_len]; + + #[cfg(test)] + eprintln!("slice: {}", str::from_utf8(slice).unwrap()); + + if let Ok(s) = std::str::from_utf8(slice) { + color_state.set_colors(s); + } + + // add to body_idx the indexes we consumed + body_idx += part_str_len - part.len(); + } + } + _ => handle_colors_and_attrs(part, color_state, attrs), + } + // + 1 for the separator + body_idx += part.len() + 1; + } +} + +fn handle_colors_and_attrs(body: &[u8], color_state: &mut ColorState, attrs: &mut Attributes) { + match *body { + // Attribute sgr mapping to crossterm + [b'0'] => { + *color_state = ColorState::default(); // uses config colors + *attrs = Attributes::default(); + } + [b'1'] => attrs.set(Attribute::Bold), + [b'2'] => attrs.set(Attribute::Dim), + [b'3'] => attrs.set(Attribute::Italic), + [b'4'] => attrs.set(Attribute::Underlined), + [b'4', b':', b'2'] => attrs.set(Attribute::DoubleUnderlined), + [b'4', b':', b'3'] => attrs.set(Attribute::Undercurled), + [b'4', b':', b'4'] => attrs.set(Attribute::Underdotted), + [b'4', b':', b'5'] => attrs.set(Attribute::Underdashed), + [b'5'] => attrs.set(Attribute::SlowBlink), + [b'6'] => attrs.set(Attribute::RapidBlink), + [b'7'] => attrs.set(Attribute::Reverse), + [b'8'] => attrs.set(Attribute::Hidden), + [b'9'] => attrs.set(Attribute::CrossedOut), + [b'2', b'0'] => attrs.set(Attribute::Fraktur), + [b'2', b'1'] => attrs.set(Attribute::NoBold), + [b'2', b'2'] => attrs.set(Attribute::NormalIntensity), + [b'2', b'3'] => attrs.set(Attribute::NoItalic), + [b'2', b'4'] => attrs.set(Attribute::NoUnderline), + [b'2', b'5'] => attrs.set(Attribute::NoBlink), + [b'2', b'7'] => attrs.set(Attribute::NoReverse), + [b'2', b'8'] => attrs.set(Attribute::NoHidden), + [b'2', b'9'] => attrs.set(Attribute::NotCrossedOut), + [b'5', b'1'] => attrs.set(Attribute::Framed), + [b'5', b'2'] => attrs.set(Attribute::Encircled), + [b'5', b'3'] => attrs.set(Attribute::OverLined), + [b'5', b'4'] => attrs.set(Attribute::NotFramedOrEncircled), + [b'5', b'5'] => attrs.set(Attribute::NotOverLined), + // Basic 8 foreground + [b'3', b'0'] => color_state.set_fg(Color::Black), + [b'3', b'1'] => color_state.set_fg(Color::DarkRed), + [b'3', b'2'] => color_state.set_fg(Color::DarkGreen), + [b'3', b'3'] => color_state.set_fg(Color::DarkYellow), + [b'3', b'4'] => color_state.set_fg(Color::DarkBlue), + [b'3', b'5'] => color_state.set_fg(Color::DarkMagenta), + [b'3', b'6'] => color_state.set_fg(Color::DarkCyan), + [b'3', b'7'] => color_state.set_fg(Color::Grey), + // Basic 8 background + [b'4', b'0'] => color_state.set_bg(Color::Black), + [b'4', b'1'] => color_state.set_bg(Color::DarkRed), + [b'4', b'2'] => color_state.set_bg(Color::DarkGreen), + [b'4', b'3'] => color_state.set_bg(Color::DarkYellow), + [b'4', b'4'] => color_state.set_bg(Color::DarkBlue), + [b'4', b'5'] => color_state.set_bg(Color::DarkMagenta), + [b'4', b'6'] => color_state.set_bg(Color::DarkCyan), + [b'4', b'7'] => color_state.set_bg(Color::Grey), + // Basic 8 bright foreground + [b'9', b'0'] => color_state.set_fg(Color::DarkGrey), + [b'9', b'1'] => color_state.set_fg(Color::Red), + [b'9', b'2'] => color_state.set_fg(Color::Green), + [b'9', b'3'] => color_state.set_fg(Color::Yellow), + [b'9', b'4'] => color_state.set_fg(Color::Blue), + [b'9', b'5'] => color_state.set_fg(Color::Magenta), + [b'9', b'6'] => color_state.set_fg(Color::Cyan), + [b'9', b'7'] => color_state.set_fg(Color::White), + // Basic 8 bright background + [b'1', b'0', b'0'] => color_state.set_bg(Color::DarkGrey), + [b'1', b'0', b'1'] => color_state.set_bg(Color::Red), + [b'1', b'0', b'2'] => color_state.set_bg(Color::Green), + [b'1', b'0', b'3'] => color_state.set_bg(Color::Yellow), + [b'1', b'0', b'4'] => color_state.set_bg(Color::Blue), + [b'1', b'0', b'5'] => color_state.set_bg(Color::Magenta), + [b'1', b'0', b'6'] => color_state.set_bg(Color::Cyan), + [b'1', b'0', b'7'] => color_state.set_bg(Color::White), + // Set colors to default + [b'3', b'9'] => color_state.set_fg(Color::Reset), + [b'4', b'9'] => color_state.set_bg(Color::Reset), + _ => {} // Ignore rest + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + configs::{ConfigOverride, get_config, initialize_config}, + ui::ColorState, + }; + use crossterm::style::{Attribute, Attributes, Color}; + + const CONF_OR: ConfigOverride = ConfigOverride { + color: None, + out_dir: None, + exit_script: None, + }; + + struct Case<'a> { + seq: &'a [u8], + expected_fg: Option, + expected_bg: Option, + expected_attrs: Vec, + label: &'a str, + } + + #[allow(clippy::trivially_copy_pass_by_ref)] + fn has_all(attrs: &Attributes, expected: &[Attribute]) -> bool { + expected.iter().all(|a| attrs.has(*a)) + } + + fn test_cases(cases: &Vec) { + for (idx, case) in cases.iter().enumerate() { + let mut color_state = ColorState::default(); + let mut attrs = Attributes::default(); + + process_colors(case.seq, &mut color_state, &mut attrs); + + assert_eq!( + color_state.get_colors().foreground, + case.expected_fg, + "Case# {} - Foreground mismatch on '{}'", + idx, + case.label + ); + assert_eq!( + color_state.get_colors().background, + case.expected_bg, + "Case# {} - Background mismatch on '{}'", + idx, + case.label + ); + assert!( + has_all(&attrs, &case.expected_attrs), + "Case# {} - Missing attrs on '{}': expected {:?}, got {:?}", + idx, + case.label, + case.expected_attrs, + attrs + ); + } + } + + #[test] + fn test_basic_fg() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[31m", + expected_fg: Some(Color::DarkRed), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG basic red", + }, + Case { + seq: b"\x1b[37m", + expected_fg: Some(Color::Grey), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG basic white/grey", + }, + ]; + test_cases(&cases); + } + + #[test] + fn test_basic_bg() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + + let cases = vec![Case { + seq: b"\x1b[44m", + expected_fg: Some(fg), + expected_bg: Some(Color::DarkBlue), + expected_attrs: vec![], + label: "BG basic blue", + }]; + test_cases(&cases); + } + + #[test] + fn test_bright_colors() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[95m", + expected_fg: Some(Color::Magenta), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG bright magenta", + }, + Case { + seq: b"\x1b[106m", + expected_fg: Some(fg), + expected_bg: Some(Color::Cyan), + expected_attrs: vec![], + label: "BG bright cyan", + }, + ]; + test_cases(&cases); + } + + #[test] + fn test_resets_and_defaults() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[0m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Reset", + }, + Case { + seq: b"\x1b[39m", + expected_fg: Some(Color::Reset), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Reset FG default", + }, + Case { + seq: b"\x1b[49m", + expected_fg: Some(fg), + expected_bg: Some(Color::Reset), + expected_attrs: vec![], + label: "Reset BG default", + }, + ]; + test_cases(&cases); + } + + #[test] + fn test_attributes() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![Case { + seq: b"\x1b[1;3;4m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], + label: "Bold + Italic + Underlined", + }]; + test_cases(&cases); + } + + #[test] + fn test_256_color_palette() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[38;5;196m", + expected_fg: Some(Color::AnsiValue(196)), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG 256 red", + }, + Case { + seq: b"\x1b[48;5;27m", + expected_fg: Some(fg), + expected_bg: Some(Color::AnsiValue(27)), + expected_attrs: vec![], + label: "BG 256 blue", + }, + ]; + test_cases(&cases); + } + + #[test] + fn test_truecolor_palette() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[38;2;255;128;64m", + expected_fg: Some(Color::Rgb { + r: 255, + g: 128, + b: 64, + }), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG truecolor orange", + }, + Case { + seq: b"\x1b[48;2;10;20;30m", + expected_fg: Some(fg), + expected_bg: Some(Color::Rgb { + r: 10, + g: 20, + b: 30, + }), + expected_attrs: vec![], + label: "BG truecolor dark", + }, + ]; + test_cases(&cases); + } + + #[test] + fn test_mix_attr_colors() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[1;3;4;38;5;202m", + expected_fg: Some(Color::AnsiValue(202)), + expected_bg: Some(bg), + expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], + label: "Bold + Italic + Underlined + FG 256 orange", + }, + Case { + seq: b"\x1b[5;7;48;2;128;64;200m", + expected_fg: Some(fg), + expected_bg: Some(Color::Rgb { + r: 128, + g: 64, + b: 200, + }), + expected_attrs: vec![Attribute::SlowBlink, Attribute::Reverse], + label: "Blink + Reverse + BG truecolor purple", + }, + ]; + test_cases(&cases); + } + + #[test] + fn test_kitchen_sink() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[1;3;38;5;202;4;48;2;10;20;30m", + expected_fg: Some(Color::AnsiValue(202)), + expected_bg: Some(Color::Rgb { + r: 10, + g: 20, + b: 30, + }), + expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], + label: "Bold + Italic + Underlined + FG 256 + BG truecolor", + }, + Case { + seq: b"\x1b[20;53m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![Attribute::Fraktur, Attribute::OverLined], + label: "Fraktur + Overlined", + }, + ]; + test_cases(&cases); + } + + #[test] + fn test_invalid() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[38;5m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Incomplete 256 FG (missing index)", + }, + Case { + seq: b"\x1b[48;5;999m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Out-of-range 256 BG (999)", + }, + Case { + seq: b"\x1b[38;2;255;0m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Incomplete truecolor FG (missing B)", + }, + Case { + seq: b"\x1b[48;2;256;256;256m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Invalid RGB components (>255)", + }, + Case { + seq: b"\x1b[999m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Unknown SGR param", + }, + ]; + test_cases(&cases); + } +} diff --git a/sericom-core/src/ui/ascii/mod.rs b/sericom-core/src/ui/ascii/mod.rs index 9b6d5a5..ad76b75 100644 --- a/sericom-core/src/ui/ascii/mod.rs +++ b/sericom-core/src/ui/ascii/mod.rs @@ -1,8 +1,10 @@ +mod colors; mod parser; +pub use colors::*; pub mod process; #[cfg(test)] -mod test; +pub(crate) mod test; pub(crate) use parser::*; diff --git a/sericom-core/src/ui/ascii/process.rs b/sericom-core/src/ui/ascii/process.rs index 7b119e8..fd99a5b 100644 --- a/sericom-core/src/ui/ascii/process.rs +++ b/sericom-core/src/ui/ascii/process.rs @@ -1,10 +1,10 @@ -use crossterm::style::{Attribute, Attributes, Color}; +use crossterm::style::Attributes; use crate::{ screen_buffer::ScreenBuffer, ui::{ - BK, BS, CR, Cell, Cursor, ESC, FF, Line, NL, ParserEvent, Rect, SEP, Span, TAB, - line::ColorState, + BS, CR, Cell, Cursor, FF, Line, NL, ParserEvent, Rect, Span, TAB, is_graphics_seq, + line::ColorState, process_colors, }, }; @@ -64,11 +64,7 @@ impl ScreenBuffer { } ParserEvent::EscapeSequence(seq) => { // Verify it is a color sequence 'ESC[_m' - if seq.len() >= 3 - && seq[0] == ESC - && seq[1] == BK - && *seq.last().expect("Verified len != 0") == b'm' - { + if is_graphics_seq(seq) { // Start a new span for change in graphics if !curr_span.is_empty() { curr_span.shrink(); @@ -76,162 +72,12 @@ impl ScreenBuffer { curr_span = Span::reserve_new(span_cap(&curr_line, &self.rect), None, None); } - extract_color_seq(&mut color_state, &mut attrs, seq); - curr_span.set_colors(&color_state); + process_colors(seq, &mut color_state, &mut attrs); curr_span.set_attrs(attrs); + curr_span.set_colors(&color_state); } } } } } } - -fn extract_color_seq(color_state: &mut ColorState, attrs: &mut Attributes, seq: &[u8]) { - // Get the part between 'ESC[' and 'm' - let body = &seq[2..seq.len() - 1]; - eprintln!("BODY: {}", String::from_utf8_lossy(body)); - - // If none, it is just setting a graphics mode - let Some(first_part_idx) = body.iter().position(|&b| b == SEP) else { - process_graphics_mode(color_state, attrs, body); - eprintln!("SHORT-CIRCUIT-EXTRACTED: {}", String::from_utf8_lossy(body)); - return; - }; - - let (mode, color_bytes) = body.split_at(first_part_idx); - eprintln!( - "MODE: {}, COLOR_BYTES: {}", - String::from_utf8_lossy(mode), - String::from_utf8_lossy(color_bytes) - ); - - // If true, color_str skips the graphics_mode part - let color_str = if process_graphics_mode(color_state, attrs, mode) { - &color_bytes[1..] - } else { - body - }; - - if let Ok(color_str) = str::from_utf8(color_str) { - color_state.set_colors(color_str); - } -} - -fn process_graphics_mode( - color_state: &mut ColorState, - attrs: &mut Attributes, - body: &[u8], -) -> bool { - let attr = match *body { - [b'0'] => { - *color_state = ColorState::default(); - Attribute::Reset - } - [b'1'] => Attribute::Bold, - [b'2'] => Attribute::Dim, - [b'3'] => Attribute::Italic, - [b'4'] => Attribute::Underlined, - [b'5'] => Attribute::SlowBlink, - [b'7'] => Attribute::Reverse, - [b'8'] => Attribute::Hidden, - [b'9'] => Attribute::CrossedOut, - [b'2', b'2'] => Attribute::NoBold, - [b'2', b'3'] => Attribute::NoItalic, - [b'2', b'4'] => Attribute::NoUnderline, - [b'2', b'5'] => Attribute::NoBlink, - [b'2', b'7'] => Attribute::NoReverse, - [b'2', b'8'] => Attribute::NoHidden, - [b'2', b'9'] => Attribute::NotCrossedOut, - _ => { - // try parsing foreground colors i.e. ESC[34m - // if let Ok(ascii_str) = str::from_utf8(body) { - // eprintln!("ASCII_STR: {}", ascii_str); - // color_state.set_colors(ascii_str); - // } - // handle colors like "30".."37", "40".."47", "90".."97", "100".."107" - if let Ok(s) = std::str::from_utf8(body) { - if let Ok(num) = s.parse::() { - match num { - // basic FG - 30 => color_state.set_fg(Color::Black), - 31 => color_state.set_fg(Color::DarkRed), - 32 => color_state.set_fg(Color::DarkGreen), - 33 => color_state.set_fg(Color::DarkYellow), - 34 => color_state.set_fg(Color::DarkBlue), - 35 => color_state.set_fg(Color::DarkMagenta), - 36 => color_state.set_fg(Color::DarkCyan), - 37 => color_state.set_fg(Color::Grey), - // basic BG - 40 => color_state.set_bg(Color::Black), - 41 => color_state.set_bg(Color::DarkRed), - 42 => color_state.set_bg(Color::DarkGreen), - 43 => color_state.set_bg(Color::DarkYellow), - 44 => color_state.set_bg(Color::DarkBlue), - 45 => color_state.set_bg(Color::DarkMagenta), - 46 => color_state.set_bg(Color::DarkCyan), - 47 => color_state.set_bg(Color::Grey), - // bright FG - 90 => color_state.set_fg(Color::DarkGrey), - 91 => color_state.set_fg(Color::Red), - 92 => color_state.set_fg(Color::Green), - 93 => color_state.set_fg(Color::Yellow), - 94 => color_state.set_fg(Color::Blue), - 95 => color_state.set_fg(Color::Magenta), - 96 => color_state.set_fg(Color::Cyan), - 97 => color_state.set_fg(Color::White), - // bright BG - 100 => color_state.set_bg(Color::DarkGrey), - 101 => color_state.set_bg(Color::Red), - 102 => color_state.set_bg(Color::Green), - 103 => color_state.set_bg(Color::Yellow), - 104 => color_state.set_bg(Color::Blue), - 105 => color_state.set_bg(Color::Magenta), - 106 => color_state.set_bg(Color::Cyan), - 107 => color_state.set_bg(Color::White), - _ => {} - } - } - } - return false; - } // ignore the rest - }; - if *body == [b'2', b'2'] { - attrs.set(Attribute::NormalIntensity); - } - attrs.set(attr); - true -} -// pub fn process_events( -// writer: &mut W, -// events: Vec, -// ) -> miette::Result<()> { -// let mut color_state = ColorState::default(); -// for ev in events { -// match &ev { -// ParserEvent::Text(t) => { -// if let Ok(s) = std::str::from_utf8(t) { -// color_state.queue_line(writer, s)?; -// } -// } -// ParserEvent::Control(b) => { -// match *b { -// BS => queue!(writer, cursor::MoveLeft(1)).into_diagnostic()?, -// CR => queue!(writer, cursor::MoveToColumn(0)).into_diagnostic()?, -// // Need to handle creating new empty buffer -// FF => queue!(writer, Clear(ClearType::All)).into_diagnostic()?, -// NL => queue!(writer, Print("\n")).into_diagnostic()?, -// TAB => queue!(writer, Print("\t")).into_diagnostic()?, -// _ => {} -// } -// } -// ParserEvent::EscapeSequence(seq) => { -// // Verify it is a color sequence 'ESC[_m' -// if seq.len() >= 3 && seq[0] == 0x1B && seq[1] == b'[' && *seq.last().unwrap() == b'm' { -// extract_color_seq(writer, &mut color_state, seq)?; -// } -// } -// } -// } -// writer.flush().ok(); -// Ok(()) -// } diff --git a/sericom-core/src/ui/ascii/test.rs b/sericom-core/src/ui/ascii/test.rs index 43e1f0b..f1dbf82 100644 --- a/sericom-core/src/ui/ascii/test.rs +++ b/sericom-core/src/ui/ascii/test.rs @@ -1,12 +1,12 @@ use std::collections::VecDeque; -use crossterm::style::{Attributes, Colors}; +use crossterm::style::{Attribute, Attributes, Color}; use super::*; use crate::{ configs::{ConfigOverride, initialize_config}, screen_buffer::*, - ui::{Cell, Line, Position, Rect, Span}, + ui::{Line, Position, Rect}, }; const CONFIG_OVERRIDE: ConfigOverride = ConfigOverride { color: None, @@ -15,67 +15,127 @@ const CONFIG_OVERRIDE: ConfigOverride = ConfigOverride { }; const TERMINAL_SIZE: (u16, u16) = (80, 24); -// #[test] -// fn test_helloworld_parsing() { -// initialize_config(CONFIG_OVERRIDE).ok(); -// let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); -// let mut sb = ScreenBuffer::new(rect); -// let mut parser = ByteParser::new(); -// // let parsed = parser.feed(b"Hello\nWorld\n"); -// let parsed = parser.feed(TEST_BYTES); -// -// eprintln!("{parsed:#?}"); -// -// sb.process_events(parsed); -// -// eprintln!("{}", debug_dump(&sb)); -// assert_eq!(1, 2); -// } +macro_rules! setup { + ($sb:ident, $parser:ident) => { + initialize_config(CONFIG_OVERRIDE).ok(); + let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); + let mut $sb = ScreenBuffer::new(rect); + let mut $parser = ByteParser::new(); + }; + ($sb:ident, $parser:ident, $config:ident) => { + initialize_config(CONFIG_OVERRIDE).ok(); + let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); + let mut $sb = ScreenBuffer::new(rect); + let mut $parser = ByteParser::new(); + let $config = $crate::configs::get_config(); + }; +} -#[test] -fn test_color_spans() { - initialize_config(CONFIG_OVERRIDE).ok(); - let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); - let mut sb = ScreenBuffer::new(rect); - let mut parser = ByteParser::new(); - let parsed = parser.feed(b"\x1b[31mRed\x1b[34mBlue\n"); +/// Assert that a line's text contents equal `expected` +/// Usage: +/// `assert_line_eq!(sb, 1, "Hello, world!");` +macro_rules! assert_line_eq { + ($sb:expr, $line_idx:expr, $expected:expr) => {{ + let sb = &$sb; // ScreenBuffer + let line_idx = $line_idx; // which line + let expected: &str = $expected; // expected text - // eprintln!("{parsed:#?}"); + let line = sb.lines.get(line_idx).expect(&format!( + "No line at index {line_idx}, only {} lines", + sb.lines.len() + )); + let actual: String = line + .iter() + .flat_map(|span| span.iter().map(|c| c.character)) + .collect(); - sb.process_events(parsed); - // eprintln!("{}", debug_dump(&sb.lines)); - - let mut vecd = VecDeque::new(); - vecd.push_back(Line::new_empty(TERMINAL_SIZE.0.into())); - let mut new_line = Line::reserve_new(2); - - let mut red_span = Span::reserve_new( - 3, - Some(Colors::new( - crossterm::style::Color::DarkRed, - crossterm::style::Color::Reset, - )), - Some(Attributes::default()), - ); - "Red".chars().for_each(|c| red_span.push(Cell::from(c))); - new_line.push(red_span); - - let mut blue_span = Span::reserve_new( - (TERMINAL_SIZE.0 - 3).into(), - Some(Colors::new( - crossterm::style::Color::DarkBlue, - crossterm::style::Color::Reset, - )), - Some(Attributes::default()), - ); - "Blue".chars().for_each(|c| blue_span.push(Cell::from(c))); - blue_span.fill_to_width((TERMINAL_SIZE.0 - 3).into()); - new_line.push(blue_span); - vecd.push_back(new_line); - - // eprintln!("{}", debug_dump(&vecd)); - - assert_eq!(vecd, sb.lines); + if actual.trim_end() != expected { + panic!( + "Line {line_idx} mismatch!\n Expected: {:?}\n Actual: {:?}\n\nFull buffer:\n{}", + expected, + actual, + $crate::ui::test::debug_dump(&sb.lines), + ); + } + }}; +} + +/// Assert that a specific span on a line matches given text and style. +/// Usage: +/// `assert_span_eq!(sb, 1, 0, "Red", fg=Color::DarkRed, bg=Color::Reset);` +#[macro_export] +macro_rules! assert_span_eq { + ( + $sb:expr, // ScreenBuffer + $line_idx:expr, // which line + $span_idx:expr // which span in that line + $(, expected => $expected_text:expr)? + $(, fg => $fg_color:expr)? + $(, bg => $bg_color:expr)? + $(, attrs => $attrs:expr)? + ) => {{ + let sb = &$sb; + let line_idx = $line_idx; + let span_idx = $span_idx; + + let line = sb + .lines + .get(line_idx) + .expect(&format!("No line at index {line_idx}, only {} lines", sb.lines.len())); + let span = line + .iter() + .nth(span_idx) + .expect(&format!("No span at index {span_idx} in line {}", line_idx)); + + // Collect text from cells + $( + let expected_text: &str = $expected_text; + let actual_text: String = span.iter().map(|c| c.character).collect(); + if !actual_text.starts_with(expected_text) { + panic!( + "Span {span_idx} text mismatch!\n Expected: {:?}\n Actual: {:?}\n\nFull buffer:\n{}", + expected_text, + actual_text, + $crate::ui::test::debug_dump(&sb.lines), + ); + } + )? + + // Check optional style args + $( + assert_eq!( + span.colors.foreground, + Some($fg_color), + "Span {} fg color mismatch (expected {:?}, got {:?})\n{}", + span_idx, + $fg_color, + span.colors.foreground, + $crate::ui::test::debug_dump(&sb.lines), + ); + )? + $( + assert_eq!( + span.colors.background, + Some($bg_color), + "Span {} bg color mismatch (expected {:?}, got {:?})\n{}", + span_idx, + $bg_color, + span.colors.background, + $crate::ui::test::debug_dump(&sb.lines), + ); + )? + $( + assert_eq!( + span.attrs, + $attrs, + "Span {} attrs mismatch (expected {:?}, got {:?})\n{}", + span_idx, + $attrs, + span.attrs, + $crate::ui::test::debug_dump(&sb.lines), + ); + )? + }}; } fn debug_dump(lines: &VecDeque) -> String { @@ -102,30 +162,135 @@ fn debug_dump(lines: &VecDeque) -> String { out } -const TEST_BYTES: &[u8] = &[ - 8, 8, 8, 8, 8, 8, 8, 8, 8, 32, 32, 32, 32, 32, 32, 32, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 77, 111, - 116, 104, 101, 114, 98, 111, 97, 114, 100, 32, 97, 115, 115, 101, 109, 98, 108, 121, 32, 110, - 117, 109, 98, 101, 114, 32, 32, 32, 32, 32, 58, 32, 55, 51, 45, 49, 54, 54, 56, 54, 45, 48, 53, - 13, 10, 32, 45, 45, 77, 111, 114, 101, 45, 45, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 32, 32, 32, 32, - 32, 32, 32, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 13, 10, 83, 119, 105, 116, 99, 104, 62, 13, 10, 83, - 119, 105, 116, 99, 104, 62, 13, 10, 83, 119, 105, 116, 99, 104, 62, 115, 104, 32, 118, 101, - 114, 13, 10, 67, 105, 115, 99, 111, 32, 73, 79, 83, 32, 83, 111, 102, 116, 119, 97, 114, 101, - 44, 32, 67, 50, 57, 54, 48, 88, 32, 83, 111, 102, 116, 119, 97, 114, 101, 32, 40, 67, 50, 57, - 54, 48, 88, 45, 85, 78, 73, 86, 69, 82, 83, 65, 76, 75, 57, 45, 77, 41, 44, 32, 86, 101, 114, - 115, 105, 111, 110, 32, 49, 53, 46, 50, 40, 50, 41, 69, 53, 44, 32, 82, 69, 76, 69, 65, 83, 69, - 32, 83, 79, 70, 84, 87, 65, 82, 69, 32, 40, 102, 99, 50, 41, 13, 10, 84, 101, 99, 104, 110, - 105, 99, 97, 108, 32, 83, 117, 112, 112, 111, 114, 116, 58, 32, 104, 116, 116, 112, 58, 47, 47, - 119, 119, 119, 46, 99, 105, 115, 99, 111, 46, 99, 111, 109, 47, 116, 101, 99, 104, 115, 117, - 112, 112, 111, 114, 116, 13, 10, 67, 111, 112, 121, 114, 105, 103, 104, 116, 32, 40, 99, 41, - 32, 49, 57, 56, 54, 45, 50, 48, 49, 54, 32, 98, 121, 32, 67, 105, 115, 99, 111, 32, 83, 121, - 115, 116, 101, 109, 115, 44, 32, 73, 110, 99, 46, 13, 10, 67, 111, 109, 112, 105, 108, 101, - 100, 32, 84, 104, 117, 32, 48, 50, 45, 74, 117, 110, 45, 49, 54, 32, 48, 49, 58, 51, 49, 32, - 98, 121, 32, 112, 114, 111, 100, 95, 114, 101, 108, 95, 116, 101, 97, 109, 13, 10, 13, 10, 82, - 79, 77, 58, 32, 66, 111, 111, 116, 115, 116, 114, 97, 112, 32, 112, 114, 111, 103, 114, 97, - 109, 32, 105, 115, 32, 67, 50, 57, 54, 48, 88, 32, 98, 111, 111, 116, 32, 108, 111, 97, 100, - 101, 114, 13, 10, 66, 79, 79, 84, 76, 68, 82, 58, 32, 67, 50, 57, 54, 48, 88, 32, -]; - -/* -66, 111, 111, 116, 32, 76, 111, 97, 100, 101, 114, 32, 40, 67, 50, 57, 54, 48, 88, 45, 72, 66, 79, 79, 84, 45, 77, 41, 32, 86, 101, 114, 115, 105, 111, 110, 32, 49, 53, 46, 50, 40, 51, 114, 41, 69, 49, 44, 32, 82, 69, 76, 69, 65, 83, 69, 32, 83, 79, 70, 84, 87, 65, 82, 69, 32, 40, 102, 99, 49, 41, 13, 10, 13, 10, 83, 119, 105, 116, 99, 104, 32, 117, 112, 116, 105, 109, 101, 32, 105, 115, 32, 51, 32, 100, 97, 121, 115, 44, 32, 50, 32, 104, 111, 117, 114, 115, 44, 32, 49, 54, 32, 109, 105, 110, 117, 116, 101, 115, 13, 10, 83, 121, 115, 116, 101, 109, 32, 114, 101, 116, 117, 114, 110, 101, 100, 32, 116, 111, 32, 82, 79, 77, 32, 98, 121, 32, 112, 111, 119, 101, 114, 45, 111, 110, 13, 10, 83, 121, 115, 116, 101, 109, 32, 114, 101, 115, 116, 97, 114, 116, 101, 100, 32, 97, 116, 32, 48, 49, 58, 52, 55, 58, 51, 55, 32, 85, 84, 67, 32, 83, 117, 110, 32, 83, 101, 112, 32, 50, 56, 32, 50, 48, 50, 53, 13, 10, 83, 121, 115, 116, 101, 109, 32, 105, 109, 97, 103, 101, 32, 102, 105, 108, 101, 32, 105, 115, 32, 34, 102, 108, 97, 115, 104, 58, 47, 99, 50, 57, 54, 48, 120, 45, 117, 110, 105, 118, 101, 114, 115, 97, 108, 107, 57, 45, 109, 122, 46, 49, 53, 50, 45, 50, 46, 69, 53, 47, 99, 50, 57, 54, 48, 120, 45, 117, 110, 105, 118, 101, 114, 115, 97, 108, 107, 57, 45, 109, 122, 46, 49, 53, 50, 45, 50, 46, 69, 53, 46, 98, 105, 110, 34, 13, 10, 76, 97, 115, 116, 32, 114, 101, 108, 111, 97, 100, 32, 114, 101, 97, 115, 111, 110, 58, 32, 112, 111, 119, 101, 114, 45, 111, 110, 13, 10, 13, 10, 13, 10, 13, 10, 84, 104, 105, 115, 32, 112, 114, 111, 100, 117, 99, 116, 32, 99, 111, 110, 116, 97, 105, 110, 115, 32, 99, 114, 121, 112, 116, 111, 103, 114, 97, 112, 104, 105, 99, 32, 102, 101, 97, 116, 117, 114, 101, 115, 32, 97, 110, 100, 32, 105, 115, 32, 115, 117, 98, 106, 101, 99, 116, 32, 116, 111, 32, 85, 110, 105, 116, 101, 100, 13, 10, 83, 116, 97, 116, 101, 115, 32, 97, 110, 100, 32, 108, 111, 99, 97, 108, 32, 99, 111, 117, 110, 116, 114, 121, 32, 108, 97, 119, 115, 32, 103, 111, 118, 101, 114, 110, 105, 110, 103, 32, 105, 109, 112, 111, 114, 116, 44, 32, 101, 120, 112, 111, 114, 116, 44, 32, 116, 114, 97, 110, 115, 102, 101, 114, 32, 97, 110, 100, 13, 10, 117, 115, 101, 46, 32, 68, 101, 108, 105, 118, 101, 114, 121, 32, 111, 102, 32, 67, 105, 115, 99, 111, 32, 99, 114, 121, 112, 116, 111, 103, 114, 97, 112, 104, 105, 99, 32, 112, 114, 111, 100, 117, 99, 116, 115, 32, 100, 111, 101, 115, 32, 110, 111, 116, 32, 105, 109, 112, 108, 121, 13, 10, 116, 104, 105, 114, 100, 45, 112, 97, 114, 116, 121, 32, 97, 117, 116, 104, 111, 114, 105, 116, 121, 32, 116, 111, 32, 105, 109, 112, 111, 114, 116, 44, 32, 101, 120, 112, 111, 114, 116, 44, 32, 100, 105, 115, 116, 114, 105, 98, 117, 116, 101, 32, 111, 114, 32, 117, 115, 101, 32, 101, 110, 99, 114, 121, 112, 116, 105, 111, 110, 46, 13, 10, 32, 45, 45, 77, 111, 114, 101, 45, 45, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 32, 32, 32, 32, 32, 32, 32, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 73, 109, 112, 111, 114, 116, 101, 114, 115, 44, 32, 101, 120, 112, 111, 114, 116, 101, 114, 115, 44, 32, 100, 105, 115, 116, 114, 105, 98, 117, 116, 111, 114, 115, 32, 97, 110, 100, 32, 117, 115, 101, 114, 115, 32, 97, 114, 101, 32, 114, 101, 115, 112, 111, 110, 115, 105, 98, 108, 101, 32, 102, 111, 114, 13, 10, 99, 111, 109, 112, 108, 105, 97, 110, 99, 101, 32, 119, 105, 116, 104, 32, 85, 46, 83, 46, 32, 97, 110, 100, 32, 108, 111, 99, 97, 108, 32, 99, 111, 117, 110, 116, 114, 121, 32, 108, 97, 119, 115, 46, 32, 66, 121, 32, 117, 115, 105, 110, 103, 32, 116, 104, 105, 115, 32, 112, 114, 111, 100, 117, 99, 116, 32, 121, 111, 117, 13, 10, 97, 103, 114, 101, 101, 32, 116, 111, 32, 99, 111, 109, 112, 108, 121, 32, 119, 105, 116, 104, 32, 97, 112, 112, 108, 105, 99, 97, 98, 108, 101, 32, 108, 97, 119, 115, 32, 97, 110, 100, 32, 114, 101, 103, 117, 108, 97, 116, 105, 111, 110, 115, 46, 32, 73, 102, 32, 121, 111, 117, 32, 97, 114, 101, 32, 117, 110, 97, 98, 108, 101, 13, 10, 116, 111, 32, 99, 111, 109, 112, 108, 121, 32, 119, 105, 116, 104, 32, 85, 46, 83, 46, 32, 97, 110, 100, 32, 108, 111, 99, 97, 108, 32, 108, 97, 119, 115, 44, 32, 114, 101, 116, 117, 114, 110, 32, 116, 104, 105, 115, 32, 112, 114, 111, 100, 117, 99, 116, 32, 105, 109, 109, 101, 100, 105, 97, 116, 101, 108, 121, 46, 13, 10, 13, 10, 65, 32, 115, 117, 109, 109, 97, 114, 121, 32, 111, 102, 32, 85, 46, 83, 46, 32, 108, 97, 119, 115, 32, 103, 111, 118, 101, 114, 110, 105, 110, 103, 32, 67, 105, 115, 99, 111, 32, 99, 114, 121, 112, 116, 111, 103, 114, 97, 112, 104, 105, 99, 32, 112, 114, 111, 100, 117, 99, 116, 115, 32, 109, 97, 121, 32, 98, 101, 32, 102, 111, 117, 110, 100, 32, 97, 116, 58, 13, 10, 104, 116, 116, 112, 58, 47, 47, 119, 119, 119, 46, 99, 105, 115, 99, 111, 46, 99, 111, 109, 47, 119, 119, 108, 47, 101, 120, 112, 111, 114, 116, 47, 99, 114, 121, 112, 116, 111, 47, 116, 111, 111, 108, 47, 115, 116, 113, 114, 103, 46, 104, 116, 109, 108, 13, 10, 13, 10, 73, 102, 32, 121, 111, 117, 32, 114, 101, 113, 117, 105, 114, 101, 32, 102, 117, 114, 116, 104, 101, 114, 32, 97, 115, 115, 105, 115, 116, 97, 110, 99, 101, 32, 112, 108, 101, 97, 115, 101, 32, 99, 111, 110, 116, 97, 99, 116, 32, 117, 115, 32, 98, 121, 32, 115, 101, 110, 100, 105, 110, 103, 32, 101, 109, 97, 105, 108, 32, 116, 111, 13, 10, 101, 120, 112, 111, 114, 116, 64, 99, 105, 115, 99, 111, 46, 99, 111, 109, 46, 13, 10, 13, 10, 99, 105, 115, 99, 111, 32, 87, 83, 45, 67, 50, 57, 54, 48, 88, 45, 52, 56, 70, 80, 68, 45, 76, 32, 40, 65, 80, 77, 56, 54, 88, 88, 88, 41, 32, 112, 114, 111, 99, 101, 115, 115, 111, 114, 32, 40, 114, 101, 118, 105, 115, 105, 111, 110, 32, 77, 48, 41, 32, 119, 105, 116, 104, 32, 53, 50, 52, 50, 56, 56, 75, 32, 98, 121, 116, 101, 115, 32, 111, 102, 32, 109, 101, 109, 111, 114, 121, 46, 13, 10, 80, 114, 111, 99, 101, 115, 115, 111, 114, 32, 98, 111, 97, 114, 100, 32, 73, 68, 32, 70, 79, 67, 50, 48, 50, 55, 87, 49, 75, 66, 13, 10, 76, 97, 115, 116, 32, 114, 101, 115, 101, 116, 32, 102, 114, 111, 109, 32, 112, 111, 119, 101, 114, 45, 111, 110, 13, 10, 49, 32, 86, 105, 114, 116, 117, 97, 108, 32, 69, 116, 104, 101, 114, 110, 101, 116, 32, 105, 110, 116, 101, 114, 102, 97, 99, 101, 13, 10, 49, 32, 70, 97, 115, 116, 69, 116, 104, 101, 114, 110, 101, 116, 32, 105, 110, 116, 101, 114, 102, 97, 99, 101, 13, 10, 53, 48, 32, 71, 105, 103, 97, 98, 105, 116, 32, 69, 116, 104, 101, 114, 110, 101, 116, 32, 105, 110, 116, 101, 114, 102, 97, 99, 101, 115, 13, 10, 50, 32, 84, 101, 110, 32, 71, 105, 103, 97, 98, 105, 116, 32, 69, 116, 104, 101, 114, 110, 101, 116, 32, 105, 110, 116, 101, 114, 102, 97, 99, 101, 115, 13, 10, 84, 104, 101, 32, 112, 97, 115, 115, 119, 111, 114, 100, 45, 114, 101, 99, 111, 118, 101, 114, 121, 32, 109, 101, 99, 104, 97, 110, 105, 115, 109, 32, 105, 115, 32, 101, 110, 97, 98, 108, 101, 100, 46, 13, 10, 13, 10, 53, 49, 50, 75, 32, 98, 121, 116, 101, 115, 32, 111, 102, 32, 102, 108, 97, 115, 104, 45, 115, 105, 109, 117, 108, 97, 116, 101, 100, 32, 110, 111, 110, 45, 118, 111, 108, 97, 116, 105, 108, 101, 32, 99, 111, 110, 102, 105, 103, 117, 114, 97, 116, 105, 111, 110, 32, 109, 101, 109, 111, 114, 121, 46, 13, 10, 66, 97, 115, 101, 32, 101, 116, 104, 101, 114, 110, 101, 116, 32, 77, 65, 67, 32, 65, 100, 100, 114, 101, 115, 115, 32, 32, 32, 32, 32, 32, 32, 58, 32, 48, 48, 58, 65, 50, 58, 56, 57, 58, 66, 54, 58, 51, 70, 58, 48, 48, 13, 10, 32, 45, 45, 77, 111, 114, 101, 45, 45, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 32, 32, 32, 32, 32, 32, 32, 32, 8, 8, 8, 8, 8, 8, 8, 8, 8, 13, 10, 83, 119, 105, 116, 99, 104, 62, 13, 10, 83, 119, 105, 116, 99, 104, 62]; -*/ +#[test] +fn test_single_plain_line() { + setup!(sb, parser, config); + let parsed = parser.feed(b"Hello, world!\n"); + sb.process_events(parsed); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + // Expect one line with one span, fg=default, text padded + assert_line_eq!(sb, 1, "Hello, world!"); + assert_span_eq!(sb, 1, 0, fg => fg, bg => bg); +} + +#[test] +fn test_two_lines_plain_text() { + setup!(sb, parser, config); + let parsed = parser.feed(b"Hello\nWorld\n"); + sb.process_events(parsed); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + // Expected: two lines, one with "Hello" padded, one with "World" padded + assert_eq!(sb.lines.len(), 3); // initial empty line + 2 + assert_line_eq!(sb, 1, "Hello"); + assert_line_eq!(sb, 2, "World"); + assert_span_eq!(sb, 1, 0, fg => fg, bg => bg); + assert_span_eq!(sb, 2, 0, fg => fg, bg => bg); +} + +#[test] +fn test_three_color_spans() { + setup!(sb, parser, config); + let parsed = parser.feed(b"\x1b[31mRed\x1b[32mGreen\x1b[34mBlue\n"); + sb.process_events(parsed); + let bg = Color::from(&config.appearance.bg); + + assert_eq!(sb.lines.len(), 2); // initial empty + 1 line + let line = sb.lines.get(1).unwrap(); + assert_eq!(line.len(), 3); // three spans + assert_span_eq!(sb, 1, 0, expected => "Red", fg => Color::DarkRed, bg => bg); + assert_span_eq!(sb, 1, 1, expected => "Green", fg => Color::DarkGreen, bg => bg); + assert_span_eq!(sb, 1, 2, expected => "Blue", fg => Color::DarkBlue, bg => bg); +} + +#[test] +fn test_no_newline_incomplete_line() { + setup!(sb, parser, config); + let parsed = parser.feed(b"Hello"); + sb.process_events(parsed); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + // Should still only contain the initial empty line + assert_eq!(sb.lines.len(), 1); + assert_span_eq!(sb, 0, 0, expected => "", fg => fg, bg => bg); +} + +#[test] +fn test_mixed_plain_and_color() { + setup!(sb, parser, config); + let parsed = parser.feed(b"Normal \x1b[31mRed\n"); + sb.process_events(parsed); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + // Expect two spans: "Normal " default, "Red" DarkRed + let line = sb.lines.get(1).unwrap(); + assert_eq!(line.len(), 2); + assert_span_eq!(sb, 1, 0, expected => "Normal ", fg => fg, bg => bg); + assert_span_eq!(sb, 1, 1, expected => "Red", fg => Color::DarkRed, bg => bg); +} + +#[test] +fn test_bold_italic_span() { + setup!(sb, parser); + + let parsed = parser.feed(b"\x1b[1;3mHello\n"); // bold + italic + sb.process_events(parsed); + + let span_attrs = Attributes::from(Attribute::Bold) | Attributes::from(Attribute::Italic); + assert_span_eq!(sb, 1, 0, attrs => span_attrs); +} + +#[test] +#[allow(clippy::cognitive_complexity)] +fn test_multiline_multicolor() { + setup!(sb, parser, config); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let input = concat!( + // Line 1: basic color DarkRed -> text + "\x1b[31mRed ", + // switch to Cyan + "\x1b[96mCyan ", + // switch to bold, underline, DarkBlue, then some text + "\x1b[1;4;34mBoldUnderBlue\n", + // Line 2: 256-color palette FG (202 = orange) and BG (27 = blueish) + "\x1b[38;5;202;48;5;27mOrangeOnBlue ", + // reset, then normal text + "\x1b[0mResetHere\n", + // Line 3: truecolor foreground text + "\x1b[38;2;128;200;64mTrueColorGreenish ", + // add truecolor background text with italic attr + "\x1b[3;48;2;200;64;128mBgPinkItalic\n", + ) + .as_bytes(); + + let parsed = parser.feed(input); + sb.process_events(parsed); + + // Buffer should have initial empty + 3 lines + assert_eq!(sb.lines.len(), 4); + + // Line 1 + assert_line_eq!(sb, 1, "Red Cyan BoldUnderBlue"); + assert_span_eq!(sb, 1, 0, expected => "Red", fg => Color::DarkRed, bg => bg); + assert_span_eq!(sb, 1, 1, expected => "Cyan", fg => Color::Cyan, bg => bg); + let span_attrs = Attributes::from(Attribute::Bold) | Attributes::from(Attribute::Underlined); + assert_span_eq!(sb, 1, 2, expected => "BoldUnderBlue", fg => Color::DarkBlue, bg => bg, attrs => span_attrs); + + // Line 2 + assert_line_eq!(sb, 2, "OrangeOnBlue ResetHere"); + assert_span_eq!(sb, 2, 0, expected => "OrangeOnBlue", fg => Color::AnsiValue(202), bg => Color::AnsiValue(27)); + assert_span_eq!(sb, 2, 1, expected => "ResetHere", fg => fg, bg => bg, attrs => Attributes::default()); + + // Line 3 + assert_line_eq!(sb, 3, "TrueColorGreenish BgPinkItalic"); + assert_span_eq!(sb, 3, 0, expected => "TrueColorGreenish", fg => Color::Rgb { r:128, g:200, b:64 }, bg => bg); + let italic = Attributes::from(Attribute::Italic); + assert_span_eq!(sb, 3, 1, expected => "BgPinkItalic", fg => Color::Rgb { r:128, g:200, b:64 }, bg => Color::Rgb { r:200, g:64, b:128 }, attrs => italic); +} From c5af55241b495bba5705bfe84cd514cabf5e1a42 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Thu, 9 Oct 2025 02:11:16 -0500 Subject: [PATCH 04/40] refactor(render): Began handling cursor escape sequences changelog: ignore --- sericom-core/src/screen_buffer/mod.rs | 40 +++++++++---- sericom-core/src/ui/ascii/colors.rs | 9 +-- sericom-core/src/ui/ascii/cursor.rs | 84 +++++++++++++++++++++++++++ sericom-core/src/ui/ascii/mod.rs | 1 + sericom-core/src/ui/ascii/parser.rs | 10 ++-- sericom-core/src/ui/ascii/process.rs | 57 +++++++++++++----- 6 files changed, 164 insertions(+), 37 deletions(-) create mode 100644 sericom-core/src/ui/ascii/cursor.rs diff --git a/sericom-core/src/screen_buffer/mod.rs b/sericom-core/src/screen_buffer/mod.rs index 6bca652..0f98606 100644 --- a/sericom-core/src/screen_buffer/mod.rs +++ b/sericom-core/src/screen_buffer/mod.rs @@ -36,7 +36,7 @@ pub const MAX_SCROLLBACK: usize = 10000; #[derive(Debug)] pub struct ScreenBuffer { /// Scrollback buffer (all lines received from the serial connection). - /// Limited by memory. + /// Limited by [`MAX_SCROLLBACK`]. pub(crate) lines: VecDeque, /// Current view into the buffer. /// Denotes which line is at the top of the screen. @@ -47,6 +47,8 @@ pub struct ScreenBuffer { pub(crate) cursor: crate::ui::Position, /// Start of text selection. Used for highlighting and copying to clipboard. selection_start: Option<(u16, usize)>, + /// Saved cursor position from ascii escape sequence + saved_cursor: Option, /// End of text selection. Used for highlighting and copying to clipboard. selection_end: Option<(u16, usize)>, /// Configuration for the maximum amount of lines to keep in memory. @@ -63,6 +65,7 @@ impl ScreenBuffer { view_start: 0, rect, cursor: crate::ui::Position::ORIGIN, + saved_cursor: None, selection_start: None, selection_end: None, max_scrollback: MAX_SCROLLBACK, @@ -159,24 +162,42 @@ impl ScreenBuffer { } } -impl crate::ui::Cursor for ScreenBuffer { +impl ScreenBuffer { /// Sets the cursor position. - fn set_cursor_pos>(&mut self, position: P) { + pub fn set_cursor_pos>(&mut self, position: P) { self.cursor = position.into(); } + pub const fn save_cursor_pos(&mut self) { + self.saved_cursor = Some(self.cursor); + } + + pub const fn restore_cursor_pos(&mut self) { + if let Some(saved_cursor) = self.saved_cursor { + self.cursor = saved_cursor; + self.saved_cursor = None; + } + } + /// Moves the cursor left by `cells`. - fn move_cursor_left(&mut self, cells: u16) { + pub const fn move_cursor_left(&mut self, cells: u16) { self.cursor.x = self.cursor.x.saturating_sub(cells); } + /// Moves the cursor right by `cells`. + pub const fn move_cursor_right(&mut self, cells: u16) { + if self.cursor.x < self.rect.width { + self.cursor.x = self.cursor.x.saturating_add(cells); + } + } + /// Moves the cursor up by `lines`. - fn move_cursor_up(&mut self, lines: u16) { + pub const fn move_cursor_up(&mut self, lines: u16) { self.cursor.y = self.cursor.y.saturating_sub(lines); } /// Moves the cursor down by `lines`. - fn move_cursor_down(&mut self, lines: u16) { + pub fn move_cursor_down(&mut self, lines: u16) { self.cursor.y = self.cursor.y.saturating_add(lines); while usize::from(self.cursor.y) > self.lines.len() { self.lines @@ -184,13 +205,8 @@ impl crate::ui::Cursor for ScreenBuffer { } } - /// Moves the cursor right by `cells`. - fn move_cursor_right(&mut self, cells: u16) { - self.cursor.x = self.cursor.x.saturating_add(cells); - } - /// Sets the column of the cursor - fn set_cursor_col(&mut self, col: u16) { + pub const fn set_cursor_col(&mut self, col: u16) { self.cursor.x = col; } } diff --git a/sericom-core/src/ui/ascii/colors.rs b/sericom-core/src/ui/ascii/colors.rs index c33c6c0..093d0eb 100644 --- a/sericom-core/src/ui/ascii/colors.rs +++ b/sericom-core/src/ui/ascii/colors.rs @@ -1,13 +1,6 @@ use crossterm::style::{Attribute, Attributes, Color}; -use crate::ui::{BK, ColorState, ESC, SEP}; - -pub fn is_graphics_seq(seq: &[u8]) -> bool { - seq.len() >= 3 - && seq[0] == ESC - && seq[1] == BK - && *seq.last().expect("Verified len != 0") == b'm' -} +use crate::ui::{ColorState, SEP}; pub fn process_colors(seq: &[u8], color_state: &mut ColorState, attrs: &mut Attributes) { // Get the part between 'ESC[' and 'm' diff --git a/sericom-core/src/ui/ascii/cursor.rs b/sericom-core/src/ui/ascii/cursor.rs new file mode 100644 index 0000000..5083d63 --- /dev/null +++ b/sericom-core/src/ui/ascii/cursor.rs @@ -0,0 +1,84 @@ +use crate::{ + screen_buffer::ScreenBuffer, + ui::{Position, SEP}, +}; + +fn ascii_digits_to_integer(body: &[u8]) -> Option { + if body.is_empty() || !body.iter().all(u8::is_ascii_digit) { + return None; + } + Some( + body.iter() + .fold(0u16, |acc, b| acc * 10 + u16::from(b & 0x0F)), + ) +} + +pub fn process_cursor(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { + let body = &seq[2..seq.len() - 1]; + + // ESC[{row};{col}H + // move cursor to line # col # + if (kind == b'H' && !body.is_empty()) || kind == b'f' { + let mut parts = body.split(|&b| b == SEP); + let row = parts.next().and_then(ascii_digits_to_integer); + let col = parts.next().and_then(ascii_digits_to_integer); + if let (Some(r), Some(c)) = (row, col) { + sb.set_cursor_pos((r, c)); + } + } else if kind == b'n' && body == [b'6'] { + // request cursor pos + todo!(); + } else { + let nums = ascii_digits_to_integer(body); + match kind { + b'A' => { + // move cursor up # lines + if let Some(n) = nums { + sb.move_cursor_up(n); + } + } + b'B' => { + // move cursor down # lines + if let Some(n) = nums { + sb.move_cursor_down(n); + } + } + b'C' => { + // move cursor right # cols + if let Some(n) = nums { + sb.move_cursor_right(n); + } + } + b'D' => { + // move cursor left # cols + if let Some(n) = nums { + sb.move_cursor_left(n); + } + } + b'E' => { + // move to beginning of next line, + if let Some(n) = nums { + sb.set_cursor_col(0); + sb.move_cursor_down(n); + } + } + b'F' => { + // move to beginning of prev line, + if let Some(n) = nums { + sb.set_cursor_col(0); + sb.move_cursor_up(n); + } + } + b'G' => { + // move cursor to column # + if let Some(n) = nums { + sb.set_cursor_col(n); + } + } + b's' => sb.save_cursor_pos(), + b'u' => sb.restore_cursor_pos(), + b'H' => sb.set_cursor_pos(Position::ORIGIN), + _ => {} + } + } +} diff --git a/sericom-core/src/ui/ascii/mod.rs b/sericom-core/src/ui/ascii/mod.rs index ad76b75..886704d 100644 --- a/sericom-core/src/ui/ascii/mod.rs +++ b/sericom-core/src/ui/ascii/mod.rs @@ -1,4 +1,5 @@ mod colors; +mod cursor; mod parser; pub use colors::*; pub mod process; diff --git a/sericom-core/src/ui/ascii/parser.rs b/sericom-core/src/ui/ascii/parser.rs index 75bc672..882bf0b 100644 --- a/sericom-core/src/ui/ascii/parser.rs +++ b/sericom-core/src/ui/ascii/parser.rs @@ -1,19 +1,21 @@ +use crate::ui::ESC; + #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ParserEvent { +pub enum ParserEvent { Text(Vec), Control(u8), EscapeSequence(Vec), } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ParseState { +pub enum ParseState { Normal, Esc, Csi, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ByteParser { +pub struct ByteParser { state: ParseState, buffer: Vec, } @@ -37,7 +39,7 @@ impl ByteParser { } match self.state { ParseState::Normal => match b { - 0x1B => { + ESC => { if !self.buffer.is_empty() { events.push(ParserEvent::Text(std::mem::take(&mut self.buffer))); } diff --git a/sericom-core/src/ui/ascii/process.rs b/sericom-core/src/ui/ascii/process.rs index fd99a5b..ef27885 100644 --- a/sericom-core/src/ui/ascii/process.rs +++ b/sericom-core/src/ui/ascii/process.rs @@ -3,8 +3,8 @@ use crossterm::style::Attributes; use crate::{ screen_buffer::ScreenBuffer, ui::{ - BS, CR, Cell, Cursor, FF, Line, NL, ParserEvent, Rect, Span, TAB, is_graphics_seq, - line::ColorState, process_colors, + BK, BS, CR, Cell, Cursor, ESC, FF, Line, NL, ParserEvent, Rect, Span, TAB, + ascii::cursor::process_cursor, line::ColorState, process_colors, }, }; @@ -33,6 +33,7 @@ impl ScreenBuffer { // Don't want an else branch because don't want line wrap if total_cells < usize::from(self.rect.width) { curr_span.push(Cell::new(char::from(*c))); + self.move_cursor_right(1); } } } @@ -63,21 +64,51 @@ impl ScreenBuffer { } } ParserEvent::EscapeSequence(seq) => { - // Verify it is a color sequence 'ESC[_m' - if is_graphics_seq(seq) { - // Start a new span for change in graphics - if !curr_span.is_empty() { - curr_span.shrink(); - curr_line.push(curr_span); - curr_span = - Span::reserve_new(span_cap(&curr_line, &self.rect), None, None); + let Some(seq_type) = classify_escape_seq(seq) else { + todo!(); + }; + match seq_type { + EscSequenceType::Cursor(kind) => { + process_cursor(seq, kind, self); } - process_colors(seq, &mut color_state, &mut attrs); - curr_span.set_attrs(attrs); - curr_span.set_colors(&color_state); + EscSequenceType::Erase(_kind) => todo!(), + EscSequenceType::Graphics => { + if !curr_span.is_empty() { + curr_span.shrink(); + curr_line.push(curr_span); + curr_span = + Span::reserve_new(span_cap(&curr_line, &self.rect), None, None); + } + process_colors(seq, &mut color_state, &mut attrs); + curr_span.set_attrs(attrs); + curr_span.set_colors(&color_state); + } + EscSequenceType::Screen(_kind) => todo!(), } } } } } } + +enum EscSequenceType { + Cursor(u8), + Erase(u8), + Graphics, + Screen(u8), +} + +fn classify_escape_seq(seq: &[u8]) -> Option { + if seq.len() < 3 || seq[0] != ESC || seq[1] != BK { + return None; + } + + let last = *seq.last().expect("Verified len != 0"); + match last { + b'm' => Some(EscSequenceType::Graphics), + b'A'..=b'G' | b'H' | b'f' | b'n' | b's' | b'u' => Some(EscSequenceType::Cursor(last)), + b'J' | b'K' => Some(EscSequenceType::Erase(last)), + b'h' | b'l' => Some(EscSequenceType::Screen(last)), + _ => None, + } +} From ded10b0c9d9ab5c5b9c7a5b70264bf281620a2cd Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Thu, 9 Oct 2025 11:27:14 -0500 Subject: [PATCH 05/40] test: Added small crate to automate integration testing on linux changelog: ignore --- test-sericom/Cargo.lock | 7 +++ test-sericom/Cargo.toml | 6 +++ test-sericom/src/main.rs | 106 +++++++++++++++++++++++++++++++++++++++ test-sericom/src/pts.rs | 51 +++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 test-sericom/Cargo.lock create mode 100644 test-sericom/Cargo.toml create mode 100644 test-sericom/src/main.rs create mode 100644 test-sericom/src/pts.rs diff --git a/test-sericom/Cargo.lock b/test-sericom/Cargo.lock new file mode 100644 index 0000000..dc68afb --- /dev/null +++ b/test-sericom/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "test-sericom" +version = "0.1.0" diff --git a/test-sericom/Cargo.toml b/test-sericom/Cargo.toml new file mode 100644 index 0000000..3dfc5e8 --- /dev/null +++ b/test-sericom/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "test-sericom" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/test-sericom/src/main.rs b/test-sericom/src/main.rs new file mode 100644 index 0000000..3e036cb --- /dev/null +++ b/test-sericom/src/main.rs @@ -0,0 +1,106 @@ +use std::{ + io::Write, + process::{Child, Command}, + thread, + time::Duration, +}; + +mod pts; +use pts::get_pts_pair; + +fn main() -> std::io::Result<()> { + let seq = b"\x1B[12G"; + let n = parse_number_field_bytes(seq); + println!("{n:?}"); // Some(12) + + let seq = b"\x1B[46G"; + let n = parse_number_field_bytes(seq); + println!("{n:?}"); // Some(12) + + for b in b"0123456789" { + println!("{b} '{}' → {}", *b as char, b - b'0'); + } + for b in b"0123456789" { + println!("{}: {:08b} digit = {}", *b as char, b, b & 0x0F); + } + + let sub = b'5' - b'0'; + let and = b'5' & 0x0F; + println!("SUB: {:08b}, AND: {:08b}", sub, and); + // let (mut master, slave) = get_pts_pair()?; + // println!("Got slave: {slave}"); + // + // let mut seri_guard = ChildGuard { + // child: Some( + // Command::new("/home/thomas/.cargo/bin/sericom") + // .arg(slave) + // .spawn() + // .expect("Failed to start 'sericom'"), + // ), + // }; + // + // master.write_all(b"Hello from simulated serial!\r\n")?; + // master.flush()?; + // + // thread::sleep(Duration::from_secs(2)); + // + // master.write_all(b"\x1B[2J")?; + // master.write_all(b"\x1B[H")?; + // master.write_all(b"Ready>\r\n")?; + // master.flush()?; + // + // thread::sleep(Duration::from_secs(5)); + // seri_guard.wait(); + Ok(()) +} + +struct ChildGuard { + child: Option, +} + +impl ChildGuard { + fn wait(&mut self) { + if let Some(mut c) = self.child.take() { + let _ = c.wait(); + } + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + if let Some(mut c) = self.child.take() { + let _ = c.kill(); + let _ = c.wait(); + } + } +} + +#[test] +fn test_sub() { + let seq = [0x1B, b'[', b'6', b'n']; + let body = &seq[2..seq.len() - 1]; + + println!("{:?}", body); + assert_eq!(body, [b'6']); + // assert!(body.is_empty()); + assert!(body.len() == 1); +} + +fn parse_number_field_bytes(seq: &[u8]) -> Option { + let body = &seq[2..seq.len() - 1]; + if body.is_empty() || !body.iter().all(|b| b.is_ascii_digit()) { + return None; + } + + Some( + body.iter() + .fold(0u32, |acc, b| acc * 10 + (b & 0x0F) as u32) + ) +} + +#[test] +fn test_parse() { + let seq = b"\x1B[12G"; + let n = parse_number_field_bytes(seq); + println!("{n:?}"); // Some(12) +} diff --git a/test-sericom/src/pts.rs b/test-sericom/src/pts.rs new file mode 100644 index 0000000..c57c265 --- /dev/null +++ b/test-sericom/src/pts.rs @@ -0,0 +1,51 @@ +use std::{ + ffi::CStr, + fs::File, + os::fd::FromRawFd, +}; + +#[allow(non_camel_case_types)] +type c_int = i32; +#[allow(non_camel_case_types)] +type c_char = i8; + +// Declare external C functions from and +unsafe extern "C" { + fn posix_openpt(flags: c_int) -> c_int; + fn grantpt(fd: c_int) -> c_int; + fn unlockpt(fd: c_int) -> c_int; + fn ptsname(fd: c_int) -> *mut c_char; +} + +// Needed constants (from fcntl.h) +const O_RDWR: i32 = 0o00000002; +const O_NOCTTY: i32 = 0o00000400; + +pub fn get_pts_pair() -> std::io::Result<(File, &'static str)> { + use std::io::Error; + unsafe { + let master_fd: c_int = posix_openpt(O_RDWR | O_NOCTTY); + if master_fd < 0 { + panic!("posix_openpt failed: {}", Error::last_os_error()); + } + + if grantpt(master_fd) != 0 { + panic!("grantpt failed: {}", Error::last_os_error()); + } + if unlockpt(master_fd) != 0 { + panic!("unlock failed: {}", Error::last_os_error()); + } + + let pts_name = ptsname(master_fd); + if pts_name.is_null() { + panic!("pts_name failed - is null"); + } + + let slave_path = CStr::from_ptr(pts_name as *const c_char) + .to_str() + .expect("Failed to convert pts_name to CStr"); + + let master = std::fs::File::from_raw_fd(master_fd); + Ok((master, slave_path)) + } +} From ecd0a7a39b4d6881060f1adea4d67c77f2ed9fdc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:24:16 +0000 Subject: [PATCH 06/40] chore(deps): bump clap from 4.5.50 to 4.5.53 Bumps [clap](https://github.com/clap-rs/clap) from 4.5.50 to 4.5.53. - [Release notes](https://github.com/clap-rs/clap/releases) - [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md) - [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.50...clap_complete-v4.5.53) --- updated-dependencies: - dependency-name: clap dependency-version: 4.5.53 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 8 ++++---- sericom/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ee7187..02f3765 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,9 +161,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.50" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -171,9 +171,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.50" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", diff --git a/sericom/Cargo.toml b/sericom/Cargo.toml index 224301f..2756577 100644 --- a/sericom/Cargo.toml +++ b/sericom/Cargo.toml @@ -28,7 +28,7 @@ eula = false rustflags = ["-C", "target-feature=+crt-static"] [dependencies] -clap = { version = "4.5.50", features = ["derive"] } +clap = { version = "4.5.53", features = ["derive"] } sericom-core = { version = "0.7.0", path = "../sericom-core" } tracing-appender = "0.2" tracing-subscriber = "0.3.20" From 4545f02e859e13573c4a78a5dca3fee07f699b62 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Sun, 23 Nov 2025 04:06:53 -0600 Subject: [PATCH 07/40] refactor(rendering): Re-structured modules and processing pipeling Good checkpoint. Instead of one monster `ScreenBuffer::process_events` function I added `ScreenDriver` which takes the `ParserEvents` and `ScreenBuffer` to handle the processing in a cleaner fashion. Still need to tie up loose ends regarding the actual implementation of processing the incoming `ParserEvents` and then figure out how to actually print to the screen. changelog: ignore --- sericom-core/src/cli.rs | 15 +- sericom-core/src/lib.rs | 2 +- .../mod.rs => screen/buffer.rs} | 102 ++----- .../{ui/line => screen/components}/cell.rs | 0 .../{ui/line => screen/components}/line.rs | 150 +++++++++- sericom-core/src/screen/components/mod.rs | 7 + .../{ui/line => screen/components}/span.rs | 12 +- sericom-core/src/screen/driver.rs | 158 ++++++++++ sericom-core/src/screen/mod.rs | 37 +++ sericom-core/src/screen/position.rs | 275 ++++++++++++++++++ .../{ui/ascii => screen/process}/colors.rs | 67 ++++- .../{ui/ascii => screen/process}/cursor.rs | 5 +- .../src/{ui/ascii => screen/process}/mod.rs | 6 +- .../{ui/ascii => screen/process}/parser.rs | 2 +- .../src/{ui/ascii => screen/process}/test.rs | 9 +- sericom-core/src/{ui => screen}/rect.rs | 53 +++- .../src/{screen_buffer => screen}/render.rs | 0 .../{screen_buffer => screen}/ui_command.rs | 19 +- sericom-core/src/screen_buffer/cursor.rs | 96 ------ sericom-core/src/screen_buffer/escape.rs | 240 --------------- sericom-core/src/serial_actor/tasks.rs | 6 +- sericom-core/src/ui/ascii/process.rs | 114 -------- sericom-core/src/ui/buffer.rs | 11 +- sericom-core/src/ui/frame.rs | 17 +- sericom-core/src/ui/line/color_state.rs | 59 ---- sericom-core/src/ui/line/mod.rs | 8 - sericom-core/src/ui/mod.rs | 8 - sericom-core/src/ui/position.rs | 82 ------ sericom-core/src/ui/terminal.rs | 6 +- 29 files changed, 806 insertions(+), 760 deletions(-) rename sericom-core/src/{screen_buffer/mod.rs => screen/buffer.rs} (62%) rename sericom-core/src/{ui/line => screen/components}/cell.rs (100%) rename sericom-core/src/{ui/line => screen/components}/line.rs (50%) create mode 100644 sericom-core/src/screen/components/mod.rs rename sericom-core/src/{ui/line => screen/components}/span.rs (90%) create mode 100644 sericom-core/src/screen/driver.rs create mode 100644 sericom-core/src/screen/mod.rs create mode 100644 sericom-core/src/screen/position.rs rename sericom-core/src/{ui/ascii => screen/process}/colors.rs (89%) rename sericom-core/src/{ui/ascii => screen/process}/cursor.rs (96%) rename sericom-core/src/{ui/ascii => screen/process}/mod.rs (81%) rename sericom-core/src/{ui/ascii => screen/process}/parser.rs (99%) rename sericom-core/src/{ui/ascii => screen/process}/test.rs (96%) rename sericom-core/src/{ui => screen}/rect.rs (50%) rename sericom-core/src/{screen_buffer => screen}/render.rs (100%) rename sericom-core/src/{screen_buffer => screen}/ui_command.rs (94%) delete mode 100644 sericom-core/src/screen_buffer/cursor.rs delete mode 100644 sericom-core/src/screen_buffer/escape.rs delete mode 100644 sericom-core/src/ui/ascii/process.rs delete mode 100644 sericom-core/src/ui/line/color_state.rs delete mode 100644 sericom-core/src/ui/line/mod.rs delete mode 100644 sericom-core/src/ui/position.rs diff --git a/sericom-core/src/cli.rs b/sericom-core/src/cli.rs index 6b5d1df..56398dc 100644 --- a/sericom-core/src/cli.rs +++ b/sericom-core/src/cli.rs @@ -137,6 +137,7 @@ pub fn open_connection(baud: u32, port: &str) -> miette::Result { } /// Gets the settings for the `port` with the specified `baud`. +#[allow(clippy::many_single_char_names)] pub fn get_settings(baud: u32, port: &str) -> miette::Result<()> { // https://www.contec.com/support/basic-knowledge/daq-control/serial-communicatin/ let mut stdout = io::stdout(); @@ -285,15 +286,15 @@ pub fn list_serial_ports() -> miette::Result<()> { "Could not list available ports." )?; for path in ports { - if let Some(path) = path.to_str() { - let line = [path, "\r\n"].concat(); - stdout - .write(line.as_bytes()) - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())? - } else { + let Some(path) = path.to_str() else { continue; }; + + let line = [path, "\r\n"].concat(); + stdout + .write(line.as_bytes()) + .into_diagnostic() + .wrap_err("Failed to write to stdout.".red())?; } Ok(()) } diff --git a/sericom-core/src/lib.rs b/sericom-core/src/lib.rs index efdfcbd..7748dac 100644 --- a/sericom-core/src/lib.rs +++ b/sericom-core/src/lib.rs @@ -13,6 +13,6 @@ pub mod cli; pub mod configs; pub mod debug; pub mod path_utils; -pub mod screen_buffer; +pub mod screen; pub mod serial_actor; pub mod ui; diff --git a/sericom-core/src/screen_buffer/mod.rs b/sericom-core/src/screen/buffer.rs similarity index 62% rename from sericom-core/src/screen_buffer/mod.rs rename to sericom-core/src/screen/buffer.rs index 0f98606..bab7d99 100644 --- a/sericom-core/src/screen_buffer/mod.rs +++ b/sericom-core/src/screen/buffer.rs @@ -1,29 +1,4 @@ -//! This module contains the code needed for the implementation of a -//! stateful buffer that holds a history of the lines/data received -//! from the serial connection and the rendering/updating of the buffer -//! to the terminal screen (stdout). -//! -//! Simply writing the data received from the serial connection directly -//! to stdout creates one main issue: there is no history of previous lines -//! that were received from the serial connection. Without a screen buffer, -//! lines would simply be wiped from existence as they exit the terminal's screen. -//! -//! As a result, there would be no way to implement features like scrolling, -//! highlighting text (for UI purposes), and getting characters at specific -//! locations within the screen for things like copying to a clipboard. -//! -//! The screen buffer solves these issues by storing each line received from the -//! connection in a [`VecDeque`]. It is important to note that -//! currently, the **capacity of the [`VecDeque`] is hardcoded with a value of 10,000 -//! lines with [`MAX_SCROLLBACK`]**. - -mod cursor; -mod escape; -mod render; -mod ui_command; -pub use ui_command::*; - -use crate::ui::{Cursor, Line, Rect}; +use crate::ui::{self, Cursor, Line, Rect, TermPos}; use std::collections::VecDeque; /// The maximum number of lines stored in memory in [`ScreenBuffer`]. @@ -42,13 +17,13 @@ pub struct ScreenBuffer { /// Denotes which line is at the top of the screen. pub(crate) view_start: usize, /// The terminal's dimensions - pub(crate) rect: Rect, + pub(crate) rect: Rect, /// Position of the cursor within the `ScreenBuffer`. - pub(crate) cursor: crate::ui::Position, + pub(crate) cursor: ui::Position, /// Start of text selection. Used for highlighting and copying to clipboard. selection_start: Option<(u16, usize)>, /// Saved cursor position from ascii escape sequence - saved_cursor: Option, + pub(crate) saved_cursor: Option>, /// End of text selection. Used for highlighting and copying to clipboard. selection_end: Option<(u16, usize)>, /// Configuration for the maximum amount of lines to keep in memory. @@ -59,26 +34,46 @@ impl ScreenBuffer { /// Constructs a new `ScreenBuffer`. /// /// Takes the `width` and `height` of the terminal. - pub fn new(rect: Rect) -> Self { + pub fn new(rect: Rect) -> Self { let mut buffer = Self { lines: VecDeque::new(), view_start: 0, rect, - cursor: crate::ui::Position::ORIGIN, + cursor: ui::Position::ORIGIN, saved_cursor: None, selection_start: None, selection_end: None, max_scrollback: MAX_SCROLLBACK, }; // Start with an empty line - buffer.lines.push_back(Line::new_empty(rect.width.into())); + buffer.lines.push_back(Line::reserve_new(rect.width.into())); buffer } - fn width(&self) -> u16 { + const fn width(&self) -> u16 { self.rect.width } + pub(crate) fn cursor_is_at_end(&self) -> bool { + if self.lines.is_empty() { + return self.cursor.x == 0 && self.cursor.y == 0; + } + + let bottom_window_line = self.view_start + usize::from(self.rect.height); + let final_line = self.lines.len(); + let line_relative_pos = final_line - self.view_start; + + if final_line > bottom_window_line { + false + } else { + let last_cell_pos = self + .lines + .get(final_line - 1) + .map_or_else(|| 0, |line| line.last_cell_idx()); + self.cursor.x as usize == last_cell_pos && self.cursor.y as usize == line_relative_pos + } + } + pub(crate) fn line_from_cursor(&mut self) -> usize { let line_idx = self.view_start + usize::from(self.cursor.y); while line_idx > self.lines.len() { @@ -137,7 +132,8 @@ impl ScreenBuffer { } fn new_line(&mut self) { - self.set_cursor_pos((0, self.cursor.y + 1)); + // TODO: F*X THIS + // self.set_cursor_pos((0, self.cursor.y + 1)); if usize::from(self.cursor.y) >= self.lines.len() { self.lines @@ -160,13 +156,6 @@ impl ScreenBuffer { pub(crate) fn push_line(&mut self, curr_line: Line) { self.lines.push_back(curr_line); } -} - -impl ScreenBuffer { - /// Sets the cursor position. - pub fn set_cursor_pos>(&mut self, position: P) { - self.cursor = position.into(); - } pub const fn save_cursor_pos(&mut self) { self.saved_cursor = Some(self.cursor); @@ -178,35 +167,4 @@ impl ScreenBuffer { self.saved_cursor = None; } } - - /// Moves the cursor left by `cells`. - pub const fn move_cursor_left(&mut self, cells: u16) { - self.cursor.x = self.cursor.x.saturating_sub(cells); - } - - /// Moves the cursor right by `cells`. - pub const fn move_cursor_right(&mut self, cells: u16) { - if self.cursor.x < self.rect.width { - self.cursor.x = self.cursor.x.saturating_add(cells); - } - } - - /// Moves the cursor up by `lines`. - pub const fn move_cursor_up(&mut self, lines: u16) { - self.cursor.y = self.cursor.y.saturating_sub(lines); - } - - /// Moves the cursor down by `lines`. - pub fn move_cursor_down(&mut self, lines: u16) { - self.cursor.y = self.cursor.y.saturating_add(lines); - while usize::from(self.cursor.y) > self.lines.len() { - self.lines - .push_back(Line::new_default(usize::from(self.width()))); - } - } - - /// Sets the column of the cursor - pub const fn set_cursor_col(&mut self, col: u16) { - self.cursor.x = col; - } } diff --git a/sericom-core/src/ui/line/cell.rs b/sericom-core/src/screen/components/cell.rs similarity index 100% rename from sericom-core/src/ui/line/cell.rs rename to sericom-core/src/screen/components/cell.rs diff --git a/sericom-core/src/ui/line/line.rs b/sericom-core/src/screen/components/line.rs similarity index 50% rename from sericom-core/src/ui/line/line.rs rename to sericom-core/src/screen/components/line.rs index ad23b08..3bb8743 100644 --- a/sericom-core/src/ui/line/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -1,10 +1,11 @@ use std::ops::{Index, IndexMut}; -use crate::ui::Span; +use super::Span; +use crate::ui::Cell; /// Line is a wrapper around [`Vec`] and represents a line within the [`ScreenBuffer`][`super::ScreenBuffer`]. -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct Line(Vec); +#[derive(Debug, Clone, Eq, PartialEq, Default)] +pub struct Line(pub Vec); impl Line { /// Create a new line with the length/size of `width`. @@ -36,12 +37,12 @@ impl Line { /// /// [`Cell`]: crate::ui::Cell #[must_use] - pub fn reserve_new(spans: usize) -> Self { - Self(Vec::with_capacity(spans)) - } - // pub fn reserve_new(width: usize) -> Self { - // Self(vec![Span::reserve_new(width); 1]) + // pub fn reserve_new(spans: usize) -> Self { + // Self(Vec::with_capacity(spans)) // } + pub fn reserve_new(width: usize) -> Self { + Self(vec![Span::reserve_new(width, None, None); 1]) + } /// Iterates over all the [`Cell`]s within the line and sets them to [`Cell::default()`]. pub fn reset(&mut self) { @@ -86,17 +87,41 @@ impl Line { .for_each(|span| span.iter_mut().for_each(|cell| cell.is_selected = false)); } - /// Returns a reference to [`Cell`] at `idx`. - #[must_use] + /// Returns a reference to [`Span`] at `idx`. pub fn get_span(&self, idx: usize) -> Option<&Span> { self.0.get(idx) } - /// Returns a mutable reference to [`Cell`] at `idx`. + /// Returns a mutable reference to [`Span`] at `idx`. pub fn get_mut_span(&mut self, idx: usize) -> Option<&mut Span> { self.0.get_mut(idx) } + /// Returns the index of the [`Span`] and the offset within the [`Span`] for `col` + pub fn span_at_col(&self, col: usize) -> (usize, usize) { + if self.0.len() == 1 { + return (0, col); + } + + let mut acc = 0; + for (idx, span) in self.0.iter().enumerate() { + let end = acc + span.len(); + if col < end { + return (idx, col - acc); + } + acc = end; + } + (self.0.len().saturating_sub(1), self.0.last().unwrap().len()) + } + + pub fn num_filled_cells(&self) -> usize { + let mut filled_cells: usize = 0; + self.0.iter().for_each(|span| { + filled_cells += span.num_filled_cells(); + }); + filled_cells + } + pub fn num_cells(&self) -> usize { let mut num_cells = 0; self.0.iter().for_each(|span| { @@ -105,7 +130,7 @@ impl Line { num_cells } - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.0.is_empty() } @@ -120,11 +145,21 @@ impl Line { pub fn push(&mut self, span: Span) { self.0.push(span); } -} -impl Default for Line { - fn default() -> Self { - Self(Default::default()) + /// Returns the index of the last cell within a line where the [`Cell`] != [`Cell::EMPTY`] + pub fn last_cell_idx(&self) -> usize { + if self.0.is_empty() { + return 0; + } + + let mut offset = self.0.iter().map(|s| s.cells.len()).sum::(); + for span in self.0.iter().rev() { + offset -= span.cells.len(); + if let Some(pos) = span.cells.iter().rposition(|c| *c != Cell::EMPTY) { + return offset + pos; + } + } + 0 } } @@ -167,3 +202,86 @@ impl IndexMut for Line { &mut self.0[index] } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::Cell; + + #[test] + fn empty_single_span() { + let span1 = Span::new_empty(80); + let line1 = Line(vec![span1]); + + assert_eq!(line1.last_cell_idx(), 0); + } + + #[test] + fn empty_multi_span() { + let span1 = Span::new_empty(20); + let span2 = Span::new_empty(20); + let span3 = Span::new_empty(20); + let span4 = Span::new_empty(20); + let line1 = Line(vec![span1, span2, span3, span4]); + + assert_eq!(line1.last_cell_idx(), 0); + } + + #[test] + fn middle_span_filled() { + let span1 = Span::new_empty(20); + let span2 = Span::new_empty(20); + let mut span3 = Span::new_empty(20); + let span4 = Span::new_empty(20); + + let cell = Cell { + character: 'a', + is_selected: false, + }; + + *span3.cells.get_mut(10).unwrap() = cell.clone(); + + let line1 = Line(vec![span1, span2, span3, span4]); + + assert_eq!(line1.last_cell_idx(), 50); + assert_eq!(line1.get_span(2).unwrap().cells.get(10).unwrap(), &cell); + } + + #[test] + fn last_span_filled() { + let span1 = Span::new_empty(20); + let span2 = Span::new_empty(20); + let mut span3 = Span::new_empty(20); + + let cell = Cell { + character: 'a', + is_selected: false, + }; + + *span3.cells.get_mut(10).unwrap() = cell.clone(); + + let line1 = Line(vec![span1, span2, span3]); + + assert_eq!(line1.last_cell_idx(), 50); + assert_eq!(line1.get_span(2).unwrap().cells.get(10).unwrap(), &cell); + } + + #[test] + fn first_span_filled() { + let mut span1 = Span::new_empty(20); + let span2 = Span::new_empty(20); + let span3 = Span::new_empty(20); + + let cell = Cell { + character: 'a', + is_selected: false, + }; + + *span1.cells.get_mut(10).unwrap() = cell.clone(); + + let line1 = Line(vec![span1, span2, span3]); + + assert_eq!(line1.last_cell_idx(), 10); + assert_eq!(line1.get_span(0).unwrap().cells.get(10).unwrap(), &cell); + } +} diff --git a/sericom-core/src/screen/components/mod.rs b/sericom-core/src/screen/components/mod.rs new file mode 100644 index 0000000..1e5e00e --- /dev/null +++ b/sericom-core/src/screen/components/mod.rs @@ -0,0 +1,7 @@ +mod cell; +mod line; +mod span; + +pub use cell::Cell; +pub use line::Line; +pub use span::Span; diff --git a/sericom-core/src/ui/line/span.rs b/sericom-core/src/screen/components/span.rs similarity index 90% rename from sericom-core/src/ui/line/span.rs rename to sericom-core/src/screen/components/span.rs index 45b9fe2..4a38e37 100644 --- a/sericom-core/src/ui/line/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -2,10 +2,7 @@ use std::ops::{Index, IndexMut}; use crossterm::style::{Attribute, Attributes, Color, Colors}; -use crate::{ - configs::get_config, - ui::{Cell, ColorState}, -}; +use crate::{configs::get_config, screen::process::ColorState, ui::Cell}; #[derive(Debug, Clone, Eq, PartialEq)] pub struct Span { @@ -96,6 +93,13 @@ impl Span { pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Cell> { self.cells.iter_mut() } + /// Returns the number of [`Cell`]s in a [`Span`] that are not [`Cell::EMPTY`] + pub fn num_filled_cells(&self) -> usize { + self.cells + .iter() + .filter(|&cell| *cell != Cell::EMPTY) + .count() + } } impl Index for Span { diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/driver.rs new file mode 100644 index 0000000..d835dc5 --- /dev/null +++ b/sericom-core/src/screen/driver.rs @@ -0,0 +1,158 @@ +use crossterm::style::Attributes; + +use crate::screen::ScreenBuffer; +use crate::ui::{BK, BS, CR, ESC, FF, Line, NL, TAB}; +use crate::ui::{Cell, ColorState, Cursor, ParserEvent, Span, TranslatePos}; +use crate::ui::{process_colors, process_cursor}; + +pub struct ScreenDriver<'a> { + buffer: &'a mut ScreenBuffer, + color_state: ColorState, + attrs: Attributes, +} + +impl<'a> ScreenDriver<'a> { + pub fn new(buffer: &'a mut ScreenBuffer) -> Self { + Self { + buffer, + color_state: ColorState::default(), + attrs: Attributes::default(), + } + } + + pub fn process_events(&mut self, events: Vec) { + for ev in events { + match ev { + ParserEvent::Text(bytes) => self.write_text(&bytes), + ParserEvent::Control(ctrl) => self.handle_control(ctrl), + ParserEvent::EscapeSequence(seq) => self.handle_escape(&seq), + } + } + todo!() + } + + fn write_text(&mut self, bytes: &[u8]) { + let chars: Vec = bytes.iter().map(|b| char::from(*b)).collect(); + let num_chars = chars.len(); + + self.buffer.with_current_span(|span| { + for ch in chars { + span.push(Cell::new(ch)); + } + }); + self.buffer.move_cursor_right(num_chars as u16); + } + + fn handle_control(&mut self, ctrl: u8) { + match ctrl { + BS => self.buffer.move_cursor_left(1), + CR => self.buffer.set_cursor_col(0), + // Need to handle creating new empty buffer + FF => todo!(), + // FF => queue!(writer, Clear(ClearType::All)).into_diagnostic()?, + NL => { + let remainder = + self.buffer.rect.width as usize - (self.buffer.curr_line().num_cells()); + if remainder != 0 { + self.buffer.with_current_span(|span| { + span.fill_to_width(remainder); + }); + } + self.buffer.move_cursor_down(1); + } + TAB => todo!(), + _ => {} + } + } + + fn handle_escape(&mut self, seq: &[u8]) { + let Some(seq_type) = classify_escape_seq(seq) else { + todo!(); + }; + match seq_type { + EscSequenceType::Cursor(kind) => { + // TODO: Figure out span-splitting + process_cursor(seq, kind, self.buffer); + } + EscSequenceType::Erase(_kind) => todo!(), + EscSequenceType::Graphics => { + process_colors(seq, &mut self.color_state, &mut self.attrs); + self.buffer.with_current_span(|span| { + if !span.is_empty() { + span.shrink(); + // self.curr_line.push(curr_span); + // curr_span = Span::reserve_new( + // span_cap(&self.curr_line, &self.rect), + // None, + // None, + // ); + } + span.set_attrs(self.attrs); + span.set_colors(&self.color_state); + }); + } + EscSequenceType::Screen(_kind) => todo!(), + } + } +} + +impl ScreenBuffer { + fn with_current_span(&mut self, f: F) { + let buff_pos = self.to_buff(self.cursor); + + let span = { + let line = self.curr_line_mut(); + let (span_idx, _col_offset) = line.span_at_col(buff_pos.x as usize); + line.get_mut_span(span_idx).expect("verified") + }; + + f(span); + } + + fn curr_line(&mut self) -> &Line { + let pos_in_lines = self.to_buff(self.cursor); + if self.lines.get(pos_in_lines.y as usize).is_some() { + self.lines + .get(pos_in_lines.y as usize) + .expect("verified that line exists") + } else { + self.push_line(Line::reserve_new(self.rect.width as usize)); + self.lines.back().expect("is not empty") + } + } + + fn curr_line_mut(&mut self) -> &mut Line { + let pos_in_lines = self.to_buff(self.cursor); + if self.lines.get(pos_in_lines.y as usize).is_some() { + self.lines + .get_mut(pos_in_lines.y as usize) + .expect("verified that line exists") + } else { + self.push_line(Line::reserve_new(self.rect.width as usize)); + self.lines.back_mut().expect("is not empty") + } + } +} + +enum EscSequenceType { + Cursor(u8), + Erase(u8), + Graphics, + Screen(u8), +} + +fn classify_escape_seq(seq: &[u8]) -> Option { + // Ensures the sequence resembles: ESC[ + if seq.len() < 3 || seq[0] != ESC || seq[1] != BK { + return None; + } + + let last = *seq.last().expect("Verified len != 0"); + match last { + b'm' => Some(EscSequenceType::Graphics), + b'A'..=b'G' | b'H' | b'f' | b'n' | b's' | b'u' => Some(EscSequenceType::Cursor(last)), + b'J' | b'K' => Some(EscSequenceType::Erase(last)), + b'h' | b'l' => Some(EscSequenceType::Screen(last)), + _ => None, + } +} diff --git a/sericom-core/src/screen/mod.rs b/sericom-core/src/screen/mod.rs new file mode 100644 index 0000000..c06d2c2 --- /dev/null +++ b/sericom-core/src/screen/mod.rs @@ -0,0 +1,37 @@ +//! This module contains the code needed for the implementation of a +//! stateful buffer that holds a history of the lines/data received +//! from the serial connection and the rendering/updating of the buffer +//! to the terminal screen (stdout). +//! +//! Simply writing the data received from the serial connection directly +//! to stdout creates one main issue: there is no history of previous lines +//! that were received from the serial connection. Without a screen buffer, +//! lines would simply be wiped from existence as they exit the terminal's screen. +//! +//! As a result, there would be no way to implement features like scrolling, +//! highlighting text (for UI purposes), and getting characters at specific +//! locations within the screen for things like copying to a clipboard. +//! +//! The screen buffer solves these issues by storing each line received from the +//! connection in a [`VecDeque`]. It is important to note that +//! currently, the **capacity of the [`VecDeque`] is hardcoded with a value of 10,000 +//! lines with [`MAX_SCROLLBACK`]**. +#![allow(unused)] + +mod buffer; +mod components; +mod driver; +mod position; +pub mod process; +mod rect; +mod render; +mod ui_command; + +pub use buffer::ScreenBuffer; +pub use components::{Cell, Line, Span}; +pub use position::{BuffPos, Cursor, PosType, PosY, Position, Scope, TermPos, TranslatePos}; +pub use process::{ + ByteParser, ColorState, ParseState, ParserEvent, process_colors, process_cursor, +}; +pub use rect::Rect; +pub use ui_command::{UIAction, UICommand}; diff --git a/sericom-core/src/screen/position.rs b/sericom-core/src/screen/position.rs new file mode 100644 index 0000000..b009e6c --- /dev/null +++ b/sericom-core/src/screen/position.rs @@ -0,0 +1,275 @@ +use std::{fmt::Display, marker::PhantomData}; + +use super::Rect; +use super::ScreenBuffer; + +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] +pub struct TermPos; +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] +pub struct BuffPos; + +pub trait Scope {} +impl Scope for TermPos {} +impl Scope for BuffPos {} + +pub trait PosType { + type Y: Copy + + std::hash::Hash + + From + + std::fmt::Display + + Clone + + Default + + Eq + + PartialEq + + std::fmt::Debug + + From; +} + +impl PosType for TermPos { + type Y = u16; +} +impl PosType for BuffPos { + type Y = u32; +} + +pub type PosY = ::Y; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub struct Position { + pub(crate) x: u16, + pub(crate) y: PosY, + _phantom: PhantomData, +} + +impl Position { + pub const ORIGIN: Self = Self { + x: 0, + y: 0, + _phantom: PhantomData, + }; +} + +impl Position { + pub fn set_y(&mut self, y: PosY) { + self.y = y; + } + pub fn set_x(&mut self, x: u16) { + self.x = x; + } + pub fn set_pos_from>>(&mut self, pos: P) { + let p = pos.into(); + self.x = p.x; + self.y = p.y; + } + pub fn set(&mut self, pos: Self) { + *self = pos; + } + pub fn x(&self) -> u16 { + self.x + } + pub fn y(&self) -> PosY { + self.y + } +} + +impl Default for Position +where + S: Scope + PosType, + ::Y: Default, +{ + fn default() -> Self { + Self { + x: 0, + y: Default::default(), + _phantom: PhantomData, + } + } +} + +impl Display for Position { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + +macro_rules! impl_from { + ($from:ty, $for:path) => { + impl From<$from> for $for { + fn from((x, y): $from) -> Self { + Self { + x, + y, + _phantom: PhantomData, + } + } + } + }; + ($from:ty, $for:path, y_as = $as:ty) => { + impl From<$from> for $for { + fn from((x, y): $from) -> Self { + Self { + x, + y: y as $as, + _phantom: PhantomData, + } + } + } + }; + ($from:ty, $for:path, x_as = $as:ty) => { + impl From<$from> for $for { + fn from((x, y): $from) -> Self { + Self { + x: x as $as, + y, + _phantom: PhantomData, + } + } + } + }; +} + +//-- TermPos --// +impl_from!((u16, u16), Position); +impl_from!((u16, usize), Position, y_as = u16); +impl_from!((usize, u16), Position, x_as = u16); + +// -- BuffPos --// +impl_from!((u16, u32), Position); + +pub trait HasBounds { + type Bounds; + fn bounds(&self) -> Self::Bounds; +} + +impl HasBounds for ScreenBuffer { + type Bounds = Rect; + + fn bounds(&self) -> Self::Bounds { + self.rect + } +} + +/// Coordinates movement within the implementor based on the implementor's bounds. +/// +/// Decide what the implementor's bounds are, and implement [`HasBounds`]. Then +/// this trait can be implemented once and provide uniform movement logic throughout +/// your code based on how the movement should behave within your type's bounds. +pub trait Cursor: HasBounds { + /// Set both the x and y of the cursor's position. + fn set_cursor_pos

(&mut self, position: P) + where + P: Into>; + /// Move the cursor left relative to it's current y position. + fn move_cursor_left(&mut self, cells: u16); + /// Move the cursor right relative to it's current x position. + fn move_cursor_right(&mut self, cells: u16); + /// Move the cursor up relative to it's current y position. + fn move_cursor_up(&mut self, lines: u16); + /// Move the cursor down relative to it's current y position. + fn move_cursor_down(&mut self, lines: u16); + /// Set the cursor's x position directly. + /// + /// Useful when the movement is not relative to the cursor's current + /// position, but a direct/absolute position. + fn set_cursor_col(&mut self, col: u16); + /// Set the cursor's y position directly. + /// + /// Useful when the movement is not relative to the cursor's current + /// position, but a direct/absolute position. + fn set_cursor_row(&mut self, row: u16); +} + +impl Cursor for ScreenBuffer { + fn set_cursor_pos

(&mut self, position: P) + where + P: Into>, + { + let bounds = self.bounds(); + let mut new_pos: Position = position.into(); + if new_pos.x > bounds.right() { + new_pos.x = bounds.right(); + } + if new_pos.y > bounds.bottom() { + new_pos.y = bounds.bottom(); + } + + self.cursor.set_pos_from(new_pos); + } + + fn move_cursor_left(&mut self, cells: u16) { + self.cursor.x = self.cursor.x.saturating_sub(cells); + } + + fn move_cursor_right(&mut self, cells: u16) { + let bounds = self.bounds(); + let mut new_x = self.cursor.x.saturating_add(cells); + if new_x > bounds.right() { + new_x = bounds.right(); + } + self.cursor.x = new_x; + } + + fn move_cursor_up(&mut self, lines: u16) { + self.cursor.y = self.cursor.y.saturating_sub(lines); + } + + // TODO: Add in logic for pushing empty/new lines to ScreenBuffer::lines + // if the cursor is trying to go past ScreenBuffer::lines.len() + fn move_cursor_down(&mut self, lines: u16) { + let bounds = self.bounds(); + let mut new_y = self.cursor.y.saturating_add(lines); + if new_y > bounds.bottom() { + new_y = bounds.bottom(); + } + self.cursor.y = new_y; + } + + fn set_cursor_col(&mut self, col: u16) { + let bounds = self.bounds(); + if col > bounds.right() { + self.cursor.x = bounds.right(); + } else { + self.cursor.x = col; + } + } + + #[allow(unused)] + fn set_cursor_row(&mut self, row: u16) { + unimplemented!() + } +} + +pub trait TranslatePos { + fn to_term(&self, pos: Position) -> Position; + fn to_buff(&self, pos: Position) -> Position; +} + +impl TranslatePos for ScreenBuffer { + fn to_term(&self, pos: Position) -> Position { + let buff_win = self.buff_rect(); + + let visible_y = pos.y.clamp(buff_win.top(), buff_win.bottom()); + let term_y = (visible_y - buff_win.top()) as u16; + + let term_x = pos.x.clamp(buff_win.left(), buff_win.right()); + + Position::::from((term_x, term_y)) + } + + fn to_buff(&self, pos: Position) -> Position { + let buff_y = (self.view_start as u32 + pos.y as u32).into(); + let buff_x = pos.x.clamp(0, self.rect.width); + + Position::::from((buff_x, buff_y)) + } +} + +impl ScreenBuffer { + pub(crate) fn buff_rect(&self) -> Rect { + Rect::from(( + (0_u16, self.view_start as u32), + self.rect.width, + self.rect.height as u32, + )) + } +} diff --git a/sericom-core/src/ui/ascii/colors.rs b/sericom-core/src/screen/process/colors.rs similarity index 89% rename from sericom-core/src/ui/ascii/colors.rs rename to sericom-core/src/screen/process/colors.rs index 093d0eb..a5af30b 100644 --- a/sericom-core/src/ui/ascii/colors.rs +++ b/sericom-core/src/screen/process/colors.rs @@ -1,6 +1,64 @@ -use crossterm::style::{Attribute, Attributes, Color}; +use crossterm::{ + queue, + style::{Attribute, Attributes, Color, Colored, Colors, Print, SetColors}, +}; +use miette::IntoDiagnostic; +use std::io::Write; -use crate::ui::{ColorState, SEP}; +use crate::configs::get_config; + +use super::SEP; + +#[derive(Debug)] +pub struct ColorState { + colors: Colors, +} + +impl Default for ColorState { + fn default() -> Self { + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + Self { + colors: Colors::new(fg, bg), + } + } +} + +impl ColorState { + pub fn get_colors(&self) -> Colors { + self.colors + } + pub fn queue_line(&self, writer: &mut W, text: &str) -> miette::Result<()> { + queue!(writer, SetColors(self.colors), Print(text)).into_diagnostic()?; + Ok(()) + } + pub fn set_fg(&mut self, color: Color) { + self.colors = Colors::new(color, self.colors.background.unwrap_or(Color::Reset)); + } + pub fn set_bg(&mut self, color: Color) { + self.colors = Colors::new(self.colors.foreground.unwrap_or(Color::Reset), color); + } + pub fn reset(&mut self) { + self.colors = Colors::new(Color::Reset, Color::Reset); + } + pub fn set_colors(&mut self, ascii_str: &str) { + self.colors = match Colored::parse_ansi(ascii_str) { + Some(colored) => { + eprintln!("{:#?}", colored); + self.colors.then(&colored.into()) + } + None => { + match Color::parse_ansi(ascii_str) { + Some(color) => eprintln!("Second try got: {:#?}", color), + None => eprintln!("Failed to parse ascii_str second time"), + } + eprintln!("Failed to parse ascii_str"); + self.colors + } + }; + } +} pub fn process_colors(seq: &[u8], color_state: &mut ColorState, attrs: &mut Attributes) { // Get the part between 'ESC[' and 'm' @@ -138,10 +196,7 @@ fn handle_colors_and_attrs(body: &[u8], color_state: &mut ColorState, attrs: &mu #[cfg(test)] mod tests { use super::*; - use crate::{ - configs::{ConfigOverride, get_config, initialize_config}, - ui::ColorState, - }; + use crate::configs::{ConfigOverride, get_config, initialize_config}; use crossterm::style::{Attribute, Attributes, Color}; const CONF_OR: ConfigOverride = ConfigOverride { diff --git a/sericom-core/src/ui/ascii/cursor.rs b/sericom-core/src/screen/process/cursor.rs similarity index 96% rename from sericom-core/src/ui/ascii/cursor.rs rename to sericom-core/src/screen/process/cursor.rs index 5083d63..27d24de 100644 --- a/sericom-core/src/ui/ascii/cursor.rs +++ b/sericom-core/src/screen/process/cursor.rs @@ -1,6 +1,7 @@ use crate::{ - screen_buffer::ScreenBuffer, - ui::{Position, SEP}, + screen::ScreenBuffer, + screen::process::SEP, + ui::{Cursor, Position}, }; fn ascii_digits_to_integer(body: &[u8]) -> Option { diff --git a/sericom-core/src/ui/ascii/mod.rs b/sericom-core/src/screen/process/mod.rs similarity index 81% rename from sericom-core/src/ui/ascii/mod.rs rename to sericom-core/src/screen/process/mod.rs index 886704d..7daf3d1 100644 --- a/sericom-core/src/ui/ascii/mod.rs +++ b/sericom-core/src/screen/process/mod.rs @@ -1,8 +1,10 @@ mod colors; mod cursor; mod parser; -pub use colors::*; -pub mod process; + +pub use colors::{ColorState, process_colors}; +pub use cursor::process_cursor; +pub use parser::{ByteParser, ParseState, ParserEvent}; #[cfg(test)] pub(crate) mod test; diff --git a/sericom-core/src/ui/ascii/parser.rs b/sericom-core/src/screen/process/parser.rs similarity index 99% rename from sericom-core/src/ui/ascii/parser.rs rename to sericom-core/src/screen/process/parser.rs index 882bf0b..b3a86d2 100644 --- a/sericom-core/src/ui/ascii/parser.rs +++ b/sericom-core/src/screen/process/parser.rs @@ -1,4 +1,4 @@ -use crate::ui::ESC; +use super::ESC; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParserEvent { diff --git a/sericom-core/src/ui/ascii/test.rs b/sericom-core/src/screen/process/test.rs similarity index 96% rename from sericom-core/src/ui/ascii/test.rs rename to sericom-core/src/screen/process/test.rs index f1dbf82..434b502 100644 --- a/sericom-core/src/ui/ascii/test.rs +++ b/sericom-core/src/screen/process/test.rs @@ -5,7 +5,7 @@ use crossterm::style::{Attribute, Attributes, Color}; use super::*; use crate::{ configs::{ConfigOverride, initialize_config}, - screen_buffer::*, + screen::*, ui::{Line, Position, Rect}, }; const CONFIG_OVERRIDE: ConfigOverride = ConfigOverride { @@ -171,6 +171,7 @@ fn test_single_plain_line() { let bg = Color::from(&config.appearance.bg); // Expect one line with one span, fg=default, text padded + assert_eq!(sb.cursor, Position { x: 13, y: 1 }); assert_line_eq!(sb, 1, "Hello, world!"); assert_span_eq!(sb, 1, 0, fg => fg, bg => bg); } @@ -178,13 +179,14 @@ fn test_single_plain_line() { #[test] fn test_two_lines_plain_text() { setup!(sb, parser, config); - let parsed = parser.feed(b"Hello\nWorld\n"); + let parsed = parser.feed(b"Hello\r\nWorld\r\n"); sb.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); // Expected: two lines, one with "Hello" padded, one with "World" padded assert_eq!(sb.lines.len(), 3); // initial empty line + 2 + assert_eq!(sb.cursor, Position { x: 0, y: 2 }); assert_line_eq!(sb, 1, "Hello"); assert_line_eq!(sb, 2, "World"); assert_span_eq!(sb, 1, 0, fg => fg, bg => bg); @@ -199,6 +201,7 @@ fn test_three_color_spans() { let bg = Color::from(&config.appearance.bg); assert_eq!(sb.lines.len(), 2); // initial empty + 1 line + assert_eq!(sb.cursor, Position { x: 12, y: 1 }); let line = sb.lines.get(1).unwrap(); assert_eq!(line.len(), 3); // three spans assert_span_eq!(sb, 1, 0, expected => "Red", fg => Color::DarkRed, bg => bg); @@ -216,6 +219,7 @@ fn test_no_newline_incomplete_line() { // Should still only contain the initial empty line assert_eq!(sb.lines.len(), 1); + assert_eq!(sb.cursor, Position { x: 5, y: 0 }); assert_span_eq!(sb, 0, 0, expected => "", fg => fg, bg => bg); } @@ -230,6 +234,7 @@ fn test_mixed_plain_and_color() { // Expect two spans: "Normal " default, "Red" DarkRed let line = sb.lines.get(1).unwrap(); assert_eq!(line.len(), 2); + assert_eq!(sb.cursor, Position { x: 10, y: 1 }); assert_span_eq!(sb, 1, 0, expected => "Normal ", fg => fg, bg => bg); assert_span_eq!(sb, 1, 1, expected => "Red", fg => Color::DarkRed, bg => bg); } diff --git a/sericom-core/src/ui/rect.rs b/sericom-core/src/screen/rect.rs similarity index 50% rename from sericom-core/src/ui/rect.rs rename to sericom-core/src/screen/rect.rs index 86f5b82..84f520a 100644 --- a/sericom-core/src/ui/rect.rs +++ b/sericom-core/src/screen/rect.rs @@ -1,19 +1,19 @@ #![allow(unused)] use std::cmp::{max, min}; -use super::Position; +use super::{BuffPos, PosType, PosY, Position, Scope, TermPos}; // #[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] -pub(crate) struct Rect { +pub struct Rect { pub(crate) width: u16, - pub(crate) height: u16, - pub(crate) origin: Position, + pub(crate) height: PosY, + pub(crate) origin: Position, } -impl Rect { +impl Rect { #[must_use] - pub const fn new(origin: Position, width: u16, height: u16) -> Self { + pub const fn new(origin: Position, width: u16, height: PosY) -> Self { Self { width, height, @@ -21,29 +21,39 @@ impl Rect { } } + /// The x value of [`Rect`]s left side #[must_use] pub const fn left(&self) -> u16 { self.origin.x } + /// The x value of [`Rect`]s right side #[must_use] pub const fn right(&self) -> u16 { self.origin.x.saturating_add(self.width) } + /// The y value of [`Rect`]s top side #[must_use] - pub const fn top(&self) -> u16 { + pub const fn top(&self) -> PosY { self.origin.y } +} +impl Rect { + /// The y value of [`Rect`]s bottom side #[must_use] pub const fn bottom(&self) -> u16 { self.origin.y.saturating_add(self.height) } +} +// These methods are only relavant to [`Rect`] +impl Rect { + /// The area (W x H) of [`Rect`] #[must_use] - pub const fn area(&self) -> u16 { - self.width.saturating_mul(self.height) + pub const fn area(&self) -> u32 { + self.width as u32 * self.height as u32 } #[must_use] @@ -60,7 +70,30 @@ impl Rect { } } -impl From<(u16, u16)> for Rect { +impl Rect { + /// The y value of [`Rect`]s bottom side + #[must_use] + pub const fn bottom(&self) -> u32 { + self.origin.y.saturating_add(self.height) + } +} + +impl From<(P, u16, PosY)> for Rect +where + P: Into>, + S: Scope + PosType, +{ + /// Create a [`Rect`] from (origin, width, height) + fn from(value: (P, u16, PosY)) -> Self { + Self { + width: value.1, + height: value.2, + origin: value.0.into(), + } + } +} + +impl From<(u16, u16)> for Rect { /// Creates a new `Rect` with (width, height) at [`Position::ORIGIN`] fn from(value: (u16, u16)) -> Self { Self { diff --git a/sericom-core/src/screen_buffer/render.rs b/sericom-core/src/screen/render.rs similarity index 100% rename from sericom-core/src/screen_buffer/render.rs rename to sericom-core/src/screen/render.rs diff --git a/sericom-core/src/screen_buffer/ui_command.rs b/sericom-core/src/screen/ui_command.rs similarity index 94% rename from sericom-core/src/screen_buffer/ui_command.rs rename to sericom-core/src/screen/ui_command.rs index ef8f5f4..59810a2 100644 --- a/sericom-core/src/screen_buffer/ui_command.rs +++ b/sericom-core/src/screen/ui_command.rs @@ -1,6 +1,7 @@ -use crate::ui::Position; +use crate::ui::{Position, TermPos}; -use super::{Cursor, Line, ScreenBuffer}; +use super::{Line, ScreenBuffer}; +use crate::ui::Cursor; /// `UICommand` is used for communication between stdin and the [`ScreenBuffer`]. #[non_exhaustive] @@ -15,22 +16,22 @@ pub enum UICommand { /// Scrolls to the beginning of the scrollback buffer (oldest line) ScrollTop, /// Starts text-selection at [`Position`] - StartSelection(Position), + StartSelection(Position), /// Updates text-selection to [`Position`] - UpdateSelection(Position), + UpdateSelection(Position), /// Copies the underlying selected text to the user's clipboard CopySelection, /// Completely clears the lines in the scrollback buffer ClearBuffer, } -pub(crate) trait UIAction { +pub trait UIAction { fn scroll_up(&mut self, lines: usize); fn scroll_down(&mut self, lines: usize); fn scroll_to_bottom(&mut self); fn scroll_to_top(&mut self); - fn start_selection(&mut self, pos: Position); - fn update_selection(&mut self, pos: Position); + fn start_selection(&mut self, pos: Position); + fn update_selection(&mut self, pos: Position); fn clear_selection(&mut self); fn copy_to_clipboard(&mut self) -> std::io::Result<()>; fn clear_buffer(&mut self); @@ -73,7 +74,7 @@ impl UIAction for ScreenBuffer { /// Sets the position within the screen for the start of a selection. /// Where `screen_x` is the x-position of the start of the selection, /// and `screen_y` is the y-position (line) of the start of the selection. - fn start_selection(&mut self, pos: Position) { + fn start_selection(&mut self, pos: Position) { let absolute_line = self.view_start + usize::from(pos.y); self.clear_selection(); self.selection_start = Some((pos.x, absolute_line)); @@ -82,7 +83,7 @@ impl UIAction for ScreenBuffer { /// Update's a selection to include the position passed to it. /// Where `screen_x` is the x-position and `screen_y` is the y-position (line). - fn update_selection(&mut self, pos: Position) { + fn update_selection(&mut self, pos: Position) { let absolute_line = self.view_start + usize::from(pos.y); self.selection_end = Some((pos.x, absolute_line)); self.update_selection_highlighting(); diff --git a/sericom-core/src/screen_buffer/cursor.rs b/sericom-core/src/screen_buffer/cursor.rs deleted file mode 100644 index bce49c1..0000000 --- a/sericom-core/src/screen_buffer/cursor.rs +++ /dev/null @@ -1,96 +0,0 @@ -// use std::fmt::Display; -// -// use super::{Line, ScreenBuffer}; -// -// /// Represent's the cursor's position within the [`ScreenBuffer`]. -// #[derive(Clone, Copy, Debug)] -// pub struct Position { -// /// The column within [`ScreenBuffer`]'s scrollback buffer. -// /// This translates to the [`Cell`][`super::Cell`] within a line (`Vec`). -// pub(crate) x: u16, -// /// The line number within [`ScreenBuffer`]'s scrollback buffer. -// pub(crate) y: usize, -// } -// -// impl Position { -// pub const ORIGIN: Self = Self { x: 0, y: 0 }; -// -// /// Sets [`Position`] to (0,0) -// pub(super) const fn home() -> Self { -// Self { x: 0, y: 0 } -// } -// } -// -// impl From<(u16, usize)> for Position { -// fn from((x, y): (u16, usize)) -> Self { -// Self { x, y } -// } -// } -// -// impl From<(u16, u16)> for Position { -// fn from((x, y): (u16, u16)) -> Self { -// Self { x, y: y as usize } -// } -// } -// -// impl From for (u16, usize) { -// fn from(position: Position) -> Self { -// (position.x, position.y) -// } -// } -// -// impl From for (u16, u16) { -// fn from(position: Position) -> Self { -// (position.x, position.y as u16) -// } -// } -// -// impl Display for Position { -// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -// write!(f, "({}, {})", self.x, self.y) -// } -// } - -// pub trait Cursor { -// fn set_cursor_pos>(&mut self, position: P); -// fn move_cursor_left(&mut self, cells: u16); -// fn move_cursor_up(&mut self, lines: u16); -// fn move_cursor_down(&mut self, lines: u16); -// fn move_cursor_right(&mut self, cells: u16); -// fn set_cursor_col(&mut self, col: u16); -// } - -// impl Cursor for ScreenBuffer { -// /// Sets the cursor position. -// fn set_cursor_pos>(&mut self, position: P) { -// self.cursor_pos = position.into(); -// } -// -// /// Moves the cursor left by `cells`. -// fn move_cursor_left(&mut self, cells: u16) { -// self.cursor_pos.x = self.cursor_pos.x.saturating_sub(cells); -// } -// -// /// Moves the cursor up by `lines`. -// fn move_cursor_up(&mut self, lines: u16) { -// self.cursor_pos.y = self.cursor_pos.y.saturating_sub(lines as usize); -// } -// -// /// Moves the cursor down by `lines`. -// fn move_cursor_down(&mut self, lines: u16) { -// self.cursor_pos.y = self.cursor_pos.y.saturating_add(lines as usize); -// while self.cursor_pos.y > self.lines.len() { -// self.lines.push_back(Line::new_default(self.width.into())); -// } -// } -// -// /// Moves the cursor right by `cells`. -// fn move_cursor_right(&mut self, cells: u16) { -// self.cursor_pos.x = self.cursor_pos.x.saturating_add(cells); -// } -// -// /// Sets the column of the cursor -// fn set_cursor_col(&mut self, col: u16) { -// self.cursor_pos.x = col; -// } -// } diff --git a/sericom-core/src/screen_buffer/escape.rs b/sericom-core/src/screen_buffer/escape.rs deleted file mode 100644 index 442e8e0..0000000 --- a/sericom-core/src/screen_buffer/escape.rs +++ /dev/null @@ -1,240 +0,0 @@ -// use crossterm::style::Attributes; -// use tracing::debug; -// -// use super::{Cursor, Line, ScreenBuffer}; -// use crate::screen_buffer::UIAction; -// -// /// `EscapeState` holds stateful information about the incoming -// /// data to allow for proper processing of ansii escape codes/characters. -// #[derive(Debug, PartialEq, Eq)] -// pub(super) enum EscapeState { -// /// Has not received ansii escape characters -// Normal, -// /// Just received an ESC (0x1B) -// Esc, -// /// Received ESC and then '[' (0x5B) -// Csi, -// } -// -// /// Represents a section of an ascii escape sequence. -// #[derive(Clone, Default, Debug, PartialEq, Eq)] -// pub(super) enum EscapePart { -// /// The default state when not actively processing an escape sequence. -// #[default] -// Empty, -// /// Collects ascii digits (0-9) as they are received individually and are -// /// eventually combined to create the final number that the `Action` will -// /// perform on i.e. `vec!['2', '3']` -> `23`. -// Numbers(Vec), -// /// The `Separator` represents the `;` used in ascii escape sequences. -// Separator, -// /// The `Action` represents the (typically) last letter of an escape -// /// sequence that determines what action is to be taken i.e. `ESC[2J`. -// Action(char), -// } -// -// /// A state-holder/collection for building ascii escape sequences -// /// from incoming data to enable proper processing/execution. -// #[derive(Clone, Debug, PartialEq, Eq)] -// pub(super) struct EscapeSequence { -// sequence: Vec, -// part: EscapePart, -// } -// -// impl EscapeSequence { -// pub(super) fn new() -> Self { -// Self { -// sequence: Vec::new(), -// part: EscapePart::Empty, -// } -// } -// -// /// Clear's the sequence and sets [`Self::part`] to [`EscapePart::Empty`]. -// pub(super) fn reset(&mut self) { -// // Clear is probably good since it will continue to -// // fill up to similar sizes throughout the program. -// self.sequence.clear(); -// self.part = EscapePart::Empty; -// } -// -// /// Appends the [`Self::part`] that is currently being processed to [`Self::sequence`]. -// fn push_part(&mut self) { -// self.sequence.push(std::mem::take(&mut self.part)); -// } -// -// /// Appends a [`EscapePart::Separator`] to [`Self::sequence`]. -// pub(super) fn insert_separator(&mut self) { -// if self.part != EscapePart::Empty { -// self.push_part(); -// } -// self.sequence.push(EscapePart::Separator); -// } -// -// /// Adds numbers to the in-progress part of the escape sequence -// /// -// /// Passes `num` as a char because `num` must be `.is_ascii_digit()` -// /// [`ScreenBuffer::parse_sequence()`] builds the ascii escape sequence -// /// line/column number from pushing `char`s to a string and parsing the -// /// `String` to `u16`. -// pub(super) fn push_num(&mut self, num: char) { -// match &mut self.part { -// EscapePart::Numbers(nums) => nums.push(num), -// _ => self.part = EscapePart::Numbers(vec![num]), -// } -// } -// -// /// Pushes the action to the escape sequence, signaling the end -// /// and results in carrying out the action for the escape sequence -// /// and then resetting its values. -// pub(super) fn push_action(&mut self, action: char) { -// if self.part != EscapePart::Empty { -// self.push_part(); -// } -// self.sequence.push(EscapePart::Action(action)); -// } -// } -// -// impl ScreenBuffer { -// /// Parse the built [`EscapeSequence`] and runs the respective action. -// pub(crate) fn parse_sequence(&mut self) { -// let span = tracing::span!(tracing::Level::DEBUG, "Escape sequence"); -// let _enter = span.enter(); -// match &self.escape_sequence.sequence[..] { -// [ -// EscapePart::Numbers(x), -// EscapePart::Separator, -// EscapePart::Numbers(y), -// EscapePart::Separator, -// EscapePart::Numbers(z), -// EscapePart::Action('m'), -// ] => { -// debug!("Got: 'ESC[{:?};{:?};{:?}m'", x, y, z); -// let x: u16 = x.iter().collect::().parse().unwrap(); -// let y: u16 = y.iter().collect::().parse().unwrap(); -// let z: u16 = z.iter().collect::().parse().unwrap(); -// if x == 0 { -// self.display_attributes = Attributes::none(); -// } -// if y == 1 { -// self.display_attributes -// .set(crossterm::style::Attribute::Bold); -// } -// if z == 7 { -// self.display_attributes -// .set(crossterm::style::Attribute::Reverse); -// } -// self.escape_state = EscapeState::Normal; -// } -// [ -// EscapePart::Numbers(line_nums), -// EscapePart::Separator, -// EscapePart::Numbers(col_nums), -// EscapePart::Action(action), -// ] => { -// debug!("Got: 'ESC[{:?};{:?}{}'", line_nums, col_nums, action); -// match action { -// // Move cursor to (line_num, col_num) -// 'H' | 'f' => { -// // Can unwrap because it is guaranteed elsewhere that -// // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). -// let mut line_num: u16 = -// line_nums.iter().collect::().parse().unwrap(); -// let col_num: u16 = col_nums.iter().collect::().parse().unwrap(); -// if line_num <= 1 { -// line_num = (self.lines.len() as u16).saturating_sub(self.height); -// } else { -// line_num += (self.lines.len() as u16).saturating_sub(self.height); -// } -// self.set_cursor_pos((col_num.saturating_sub(1), line_num)); -// } -// _ => {} -// } -// self.escape_state = EscapeState::Normal; -// } -// [ -// EscapePart::Separator, -// EscapePart::Numbers(col_nums), -// EscapePart::Action(action), -// ] => { -// debug!("Got: 'ESC[;{:?}{}'", col_nums, action); -// match action { -// // Move cursor to (same, col_num) -// 'H' | 'f' => { -// // Can unwrap because it is guaranteed elsewhere that -// // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). -// let col_num: u16 = col_nums.iter().collect::().parse().unwrap(); -// self.cursor.x = col_num.saturating_sub(1); -// } -// _ => {} -// } -// self.escape_state = EscapeState::Normal; -// } -// [EscapePart::Numbers(nums), EscapePart::Action(action)] => { -// debug!("Got: 'ESC[{:?}{}'", nums, action); -// // Can unwrap because it is guaranteed elsewhere that -// // `EscapePart::Numbers(Vec)` only holds ascii digits (0-9). -// let num: u16 = nums.iter().collect::().parse().unwrap(); -// match (num, action) { -// // Move cursor up # of lines -// (num, 'A') => self.move_cursor_up(num), -// // Move cursor down # of lines -// (num, 'B') => self.move_cursor_down(num), -// // Move cursor right # of cols -// (num, 'C') => self.move_cursor_right(num), -// // Move cursor left # of cols -// (num, 'D') => self.move_cursor_left(num), -// // Moves cursor to beginning of line, # lines down -// (num, 'E') => { -// self.set_cursor_pos((0, (self.cursor.y as u16) + num)); -// while self.cursor.y > self.lines.len() { -// self.lines.push_back(Line::new_default(self.width.into())); -// } -// } -// // Moves cursor to beginning of line, # lines up -// (num, 'F') => self.set_cursor_pos((0, (self.cursor.y as u16) - num)), -// // Moves cursor to column # -// (num, 'G') => self.set_cursor_col(num), -// // Erase from cursor until end of screen -// (0, 'J') => self.clear_from_cursor_to_eos(), -// // Erase from cursor to beginning of screen -// (1, 'J') => self.clear_from_cursor_to_sos(), -// // Erase entire screen -// (2, 'J') => self.clear_screen(), -// // Erase from cursor to end of line -// (0, 'K') => self.clear_from_cursor_to_eol(), -// // Erase start of line to cursor -// (1, 'K') => self.clear_from_cursor_to_sol(), -// // Erase entire line -// (2, 'K') => self.clear_whole_line(), -// _ => {} -// } -// self.escape_state = EscapeState::Normal; -// } -// [EscapePart::Action(action)] => { -// debug!("Got: 'ESC[{}'", action); -// match action { -// // Set cursor position to 0, 0 of screen -// 'H' => { -// self.set_cursor_pos((0, self.lines.len().saturating_sub(self.view_start))); -// } -// // Erase from cursor until end of screen -// 'J' => self.clear_from_cursor_to_eos(), -// // Erase from cursor to end of line -// 'K' => self.clear_from_cursor_to_eol(), -// 'C' => self.move_cursor_right(1), -// 'D' => self.move_cursor_left(1), -// 'm' => { -// self.display_attributes = Attributes::none(); -// } -// action if action.is_alphabetic() => {} -// _ => {} -// } -// self.escape_state = EscapeState::Normal; -// } -// other => { -// debug!("Unhandled ESC: 'ESC[{:?}'", other); -// self.escape_state = EscapeState::Normal; -// } -// } -// } -// } diff --git a/sericom-core/src/serial_actor/tasks.rs b/sericom-core/src/serial_actor/tasks.rs index cb39666..51de76c 100644 --- a/sericom-core/src/serial_actor/tasks.rs +++ b/sericom-core/src/serial_actor/tasks.rs @@ -1,7 +1,7 @@ use crate::{ - screen_buffer::*, + screen::{ByteParser, Position, Rect, ScreenBuffer, UIAction, UICommand}, serial_actor::{SerialEvent, SerialMessage}, - ui::{ByteParser, Rect, Terminal}, + ui::Terminal, }; use crossterm::{ event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}, @@ -35,7 +35,7 @@ pub async fn run_stdout_output( let mut screen_buffer = ScreenBuffer::new(Rect { width, height, - origin: crate::ui::Position::ORIGIN, + origin: Position::ORIGIN, }); let mut data_buffer = Vec::with_capacity(2048); let mut render_timer: Option = None; diff --git a/sericom-core/src/ui/ascii/process.rs b/sericom-core/src/ui/ascii/process.rs deleted file mode 100644 index ef27885..0000000 --- a/sericom-core/src/ui/ascii/process.rs +++ /dev/null @@ -1,114 +0,0 @@ -use crossterm::style::Attributes; - -use crate::{ - screen_buffer::ScreenBuffer, - ui::{ - BK, BS, CR, Cell, Cursor, ESC, FF, Line, NL, ParserEvent, Rect, Span, TAB, - ascii::cursor::process_cursor, line::ColorState, process_colors, - }, -}; - -impl ScreenBuffer { - pub(crate) fn process_events(&mut self, events: Vec) { - let mut color_state = ColorState::default(); - let mut attrs = Attributes::default(); - - let mut curr_line = Line::reserve_new(usize::from(self.rect.width)); - - let span_cap = |line: &Line, rect: &Rect| -> usize { - if !line.is_empty() { - return usize::from(rect.width) - line.num_cells(); - } - usize::from(rect.width) - }; - - let mut curr_span = Span::reserve_new(span_cap(&curr_line, &self.rect), None, None); - - for ev in events { - match &ev { - ParserEvent::Text(t) => { - for c in t { - let total_cells = curr_line.num_cells() + curr_span.len(); - - // Don't want an else branch because don't want line wrap - if total_cells < usize::from(self.rect.width) { - curr_span.push(Cell::new(char::from(*c))); - self.move_cursor_right(1); - } - } - } - ParserEvent::Control(b) => { - match *b { - BS => self.move_cursor_left(1), - CR => self.set_cursor_col(0), - // Need to handle creating new empty buffer - FF => todo!(), - // FF => queue!(writer, Clear(ClearType::All)).into_diagnostic()?, - NL => { - let remainder = usize::from(self.rect.width) - - (curr_span.len() + curr_line.num_cells()); - if remainder != 0 { - curr_span.fill_to_width(span_cap(&curr_line, &self.rect)); - curr_line.push(curr_span); - curr_span = Span::reserve_new( - span_cap(&curr_line, &self.rect), - Some(color_state.get_colors()), - Some(attrs), - ); - } - self.push_line(curr_line); - curr_line = Line::reserve_new(usize::from(self.rect.width)); - } - TAB => todo!(), - _ => {} - } - } - ParserEvent::EscapeSequence(seq) => { - let Some(seq_type) = classify_escape_seq(seq) else { - todo!(); - }; - match seq_type { - EscSequenceType::Cursor(kind) => { - process_cursor(seq, kind, self); - } - EscSequenceType::Erase(_kind) => todo!(), - EscSequenceType::Graphics => { - if !curr_span.is_empty() { - curr_span.shrink(); - curr_line.push(curr_span); - curr_span = - Span::reserve_new(span_cap(&curr_line, &self.rect), None, None); - } - process_colors(seq, &mut color_state, &mut attrs); - curr_span.set_attrs(attrs); - curr_span.set_colors(&color_state); - } - EscSequenceType::Screen(_kind) => todo!(), - } - } - } - } - } -} - -enum EscSequenceType { - Cursor(u8), - Erase(u8), - Graphics, - Screen(u8), -} - -fn classify_escape_seq(seq: &[u8]) -> Option { - if seq.len() < 3 || seq[0] != ESC || seq[1] != BK { - return None; - } - - let last = *seq.last().expect("Verified len != 0"); - match last { - b'm' => Some(EscSequenceType::Graphics), - b'A'..=b'G' | b'H' | b'f' | b'n' | b's' | b'u' => Some(EscSequenceType::Cursor(last)), - b'J' | b'K' => Some(EscSequenceType::Erase(last)), - b'h' | b'l' => Some(EscSequenceType::Screen(last)), - _ => None, - } -} diff --git a/sericom-core/src/ui/buffer.rs b/sericom-core/src/ui/buffer.rs index 96646f2..8c6c577 100644 --- a/sericom-core/src/ui/buffer.rs +++ b/sericom-core/src/ui/buffer.rs @@ -1,12 +1,9 @@ #![allow(unused)] -use super::Line; -use super::Rect; -use crate::ui::Cell; -use crate::ui::Span; +use crate::screen::{Cell, Line, Rect, Span, TermPos}; #[derive(Debug, Clone, Eq, PartialEq)] pub struct Buffer { - area: Rect, + area: Rect, content: Vec, } impl Buffer { @@ -16,12 +13,12 @@ impl Buffer { } } #[must_use] - pub fn empty(area: Rect) -> Self { + pub fn empty(area: Rect) -> Self { Self::filled(area, Span::default()) } #[must_use] - pub fn filled(area: Rect, span: Span) -> Self { + pub fn filled(area: Rect, span: Span) -> Self { let line = Line::new(area.width.into(), span); let size = area.height as usize; let content = vec![line; size]; diff --git a/sericom-core/src/ui/frame.rs b/sericom-core/src/ui/frame.rs index e339f8e..cda49a9 100644 --- a/sericom-core/src/ui/frame.rs +++ b/sericom-core/src/ui/frame.rs @@ -1,35 +1,36 @@ #![allow(unused)] -use crate::{screen_buffer::ScreenBuffer, ui::Buffer}; +use crate::screen::TermPos; +use crate::{screen::ScreenBuffer, ui::Buffer}; -use super::{position::Position, rect::Rect}; +use crate::{screen::Position, screen::Rect}; #[derive(Debug)] pub struct Frame<'a> { pub(crate) buffer: &'a mut Buffer, - pub(crate) cursor_position: Option, - pub(crate) area: Rect, + pub(crate) cursor_position: Option>, + pub(crate) area: Rect, } impl Frame<'_> { #[must_use] - pub const fn area(&self) -> Rect { + pub const fn area(&self) -> Rect { self.area } - pub fn render_widget(&mut self, widget: W, area: Rect) { + pub fn render_widget(&mut self, widget: W, area: Rect) { widget.render(area, self.buffer); } } pub trait Widget { - fn render(self, area: Rect, buf: &mut Buffer) + fn render(self, area: Rect, buf: &mut Buffer) where Self: Sized; } impl Widget for ScreenBuffer { - fn render(self, area: Rect, buf: &mut Buffer) + fn render(self, area: Rect, buf: &mut Buffer) where Self: Sized, { diff --git a/sericom-core/src/ui/line/color_state.rs b/sericom-core/src/ui/line/color_state.rs deleted file mode 100644 index 9e7db87..0000000 --- a/sericom-core/src/ui/line/color_state.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crossterm::{ - queue, - style::{Color, Colored, Colors, Print, SetColors}, -}; -use miette::IntoDiagnostic; -use std::io::Write; - -use crate::configs::get_config; - -#[derive(Debug)] -pub struct ColorState { - colors: Colors, -} - -impl Default for ColorState { - fn default() -> Self { - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); - Self { - colors: Colors::new(fg, bg), - } - } -} - -impl ColorState { - pub fn get_colors(&self) -> Colors { - self.colors - } - pub fn queue_line(&self, writer: &mut W, text: &str) -> miette::Result<()> { - queue!(writer, SetColors(self.colors), Print(text)).into_diagnostic()?; - Ok(()) - } - pub fn set_fg(&mut self, color: Color) { - self.colors = Colors::new(color, self.colors.background.unwrap_or(Color::Reset)); - } - pub fn set_bg(&mut self, color: Color) { - self.colors = Colors::new(self.colors.foreground.unwrap_or(Color::Reset), color); - } - pub fn reset(&mut self) { - self.colors = Colors::new(Color::Reset, Color::Reset); - } - pub fn set_colors(&mut self, ascii_str: &str) { - self.colors = match Colored::parse_ansi(ascii_str) { - Some(colored) => { - eprintln!("{:#?}", colored); - self.colors.then(&colored.into()) - } - None => { - match Color::parse_ansi(ascii_str) { - Some(color) => eprintln!("Second try got: {:#?}", color), - None => eprintln!("Failed to parse ascii_str second time"), - } - eprintln!("Failed to parse ascii_str"); - self.colors - } - }; - } -} diff --git a/sericom-core/src/ui/line/mod.rs b/sericom-core/src/ui/line/mod.rs deleted file mode 100644 index 6cab228..0000000 --- a/sericom-core/src/ui/line/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -mod cell; -mod color_state; -mod line; -mod span; -pub(crate) use cell::*; -pub(crate) use color_state::*; -pub(crate) use line::*; -pub(crate) use span::*; diff --git a/sericom-core/src/ui/mod.rs b/sericom-core/src/ui/mod.rs index 11c538e..e5d4242 100644 --- a/sericom-core/src/ui/mod.rs +++ b/sericom-core/src/ui/mod.rs @@ -1,15 +1,7 @@ -mod ascii; mod buffer; mod frame; -mod line; -mod position; -mod rect; mod terminal; -pub(crate) use ascii::*; pub(crate) use buffer::Buffer; pub(crate) use frame::Frame; -pub use line::*; -pub(crate) use position::{Cursor, Position}; -pub(crate) use rect::Rect; pub(crate) use terminal::Terminal; diff --git a/sericom-core/src/ui/position.rs b/sericom-core/src/ui/position.rs deleted file mode 100644 index 29305de..0000000 --- a/sericom-core/src/ui/position.rs +++ /dev/null @@ -1,82 +0,0 @@ -#![allow(unused)] - -use std::fmt::Display; - -use crate::screen_buffer::ScreenBuffer; - -#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] -pub(crate) struct Position { - pub(crate) x: u16, - pub(crate) y: u16, -} - -impl Position { - pub const ORIGIN: Self = Self { x: 0, y: 0 }; - - pub const fn set_y(&mut self, y: u16) { - self.y = y; - } - pub const fn set_x(&mut self, x: u16) { - self.x = x; - } - pub fn set_pos_from>(&mut self, pos: P) { - let p = pos.into(); - self.x = p.x; - self.y = p.y; - } - pub const fn set_pos(&mut self, pos: Self) { - *self = pos; - } - pub const fn get_x(&self) -> u16 { - self.x - } - pub const fn get_y(&self) -> u16 { - self.y - } -} - -impl From<(u16, u16)> for Position { - fn from((x, y): (u16, u16)) -> Self { - Self { x, y } - } -} - -impl From<(u16, usize)> for Position { - fn from((x, y): (u16, usize)) -> Self { - let y: u16 = y.try_into().expect("Out of scrollback buffer bounds"); - Self { x, y } - } -} - -impl From for (u16, usize) { - fn from(position: Position) -> Self { - (position.x, usize::from(position.y)) - } -} - -impl From for (usize, u16) { - fn from(position: Position) -> Self { - (usize::from(position.x), position.y) - } -} - -impl From for (u16, u16) { - fn from(position: Position) -> Self { - (position.x, position.y) - } -} - -impl Display for Position { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "({}, {})", self.x, self.y) - } -} - -pub trait Cursor { - fn set_cursor_pos>(&mut self, position: P); - fn move_cursor_left(&mut self, cells: u16); - fn move_cursor_up(&mut self, lines: u16); - fn move_cursor_down(&mut self, lines: u16); - fn move_cursor_right(&mut self, cells: u16); - fn set_cursor_col(&mut self, col: u16); -} diff --git a/sericom-core/src/ui/terminal.rs b/sericom-core/src/ui/terminal.rs index cad67ff..1cb8a7b 100644 --- a/sericom-core/src/ui/terminal.rs +++ b/sericom-core/src/ui/terminal.rs @@ -1,13 +1,13 @@ use crate::ui::{Buffer, Frame}; -use super::Rect; +use crate::screen::{Rect, TermPos}; #[derive(Debug, Clone, Eq, PartialEq)] pub struct Terminal { buffers: [Buffer; 2], current_buffer: usize, cursor_hidden: bool, - view_area: Rect, + view_area: Rect, } impl Terminal { @@ -34,7 +34,7 @@ impl Terminal { 1 - self.current_buffer } - pub(crate) const fn area(&self) -> Rect { + pub(crate) const fn area(&self) -> Rect { self.view_area } } From b719ab63b75adbd15faf571170e0bf07e843609e Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Sun, 23 Nov 2025 21:16:13 -0600 Subject: [PATCH 08/40] refactor(rendering): Cursor movement/colors now pass existing unit tests Worked out some of the knots in the cursor/color escape sequence handling and line/span manipulation. The current handling of parsed events passes the old unit tests which is good - pretty close to being up to speed. Last main todo before wiring it all up is handle screen erasure escape sequences. There are a few things that I have skipped (\r\n && screen modes) but I'm going to roll without it until I get further along and get a chance to test what is/isn't implemented based on real-world results. Did some more file re-structuring, cleaned up a bunch of lints, and cleaning out unused methods is a WOP. Updated the justfile and the test-sericom crate. changelog: ignore --- Cargo.lock | 4 + Cargo.toml | 2 +- justfile | 47 ++- sericom-core/src/cli.rs | 38 +- sericom-core/src/configs/appearance.rs | 78 ++-- sericom-core/src/configs/defaults.rs | 11 +- sericom-core/src/debug.rs | 17 +- sericom-core/src/lib.rs | 2 + sericom-core/src/screen/buffer.rs | 231 ++++++----- sericom-core/src/screen/components/cell.rs | 1 + sericom-core/src/screen/components/line.rs | 228 ++++++----- sericom-core/src/screen/components/span.rs | 20 +- sericom-core/src/screen/driver.rs | 117 +++--- sericom-core/src/screen/mod.rs | 9 +- sericom-core/src/screen/position.rs | 37 +- sericom-core/src/screen/process/colors.rs | 369 +----------------- sericom-core/src/screen/process/cursor.rs | 16 +- sericom-core/src/screen/process/mod.rs | 5 +- sericom-core/src/screen/process/screen.rs | 62 +++ .../src/screen/process/tests/colors.rs | 338 ++++++++++++++++ .../process/{test.rs => tests/escape.rs} | 144 ++++--- sericom-core/src/screen/process/tests/mod.rs | 2 + sericom-core/src/screen/rect.rs | 3 +- sericom-core/src/screen/render.rs | 6 +- sericom-core/src/screen/ui_command.rs | 157 ++++---- test-sericom/src/main.rs | 79 +--- test-sericom/src/pts.rs | 6 +- 27 files changed, 1078 insertions(+), 951 deletions(-) create mode 100644 sericom-core/src/screen/process/screen.rs create mode 100644 sericom-core/src/screen/process/tests/colors.rs rename sericom-core/src/screen/process/{test.rs => tests/escape.rs} (64%) create mode 100644 sericom-core/src/screen/process/tests/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 8ee7187..2bc97da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -848,6 +848,10 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "test-sericom" +version = "0.1.0" + [[package]] name = "textwrap" version = "0.16.2" diff --git a/Cargo.toml b/Cargo.toml index 4b07084..41b8ae1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["sericom", "sericom-core"] +members = ["sericom", "sericom-core", "test-sericom"] resolver = "3" [workspace.package] diff --git a/justfile b/justfile index e1af28b..c4758da 100644 --- a/justfile +++ b/justfile @@ -1,14 +1,45 @@ +alias l := lint +alias c := check +alias t := test +alias b := build +alias br := build-release + +default-tests := '' + +default: check lint + +build: + cargo build + +build-release: + cargo build --release + +check: + cargo check + +check-win: + cargo check --target x86_64-pc-windows-msvc + +clean: + cargo clean + +lint: + cargo clippy + +list-tests: + cargo test -- --list + run: - cargo r -- /dev/ttyUSB0 + cargo run -- /dev/ttyUSB0 run-file: - cargo r -- /dev/ttyUSB0 -f + cargo run -- /dev/ttyUSB0 -f -run-trace: - cargo r -- /dev/ttyUSB0 -d +testseri: + cargo run -p test-sericom -check-win: - cargo c --target x86_64-pc-windows-msvc +run-trace: + cargo run -- /dev/ttyUSB0 -d -test: - cargo t -p sericom-core --lib +test target=default-tests: + cargo test {{target}} --no-fail-fast --lib diff --git a/sericom-core/src/cli.rs b/sericom-core/src/cli.rs index 56398dc..8bdcdcd 100644 --- a/sericom-core/src/cli.rs +++ b/sericom-core/src/cli.rs @@ -7,7 +7,7 @@ use crate::{ create_recursive, debug::run_debug_output, map_miette, - screen_buffer::UICommand, + screen::UICommand, serial_actor::{ SerialActor, SerialEvent, SerialMessage, tasks::{run_file_output, run_stdin_input, run_stdout_output}, @@ -65,31 +65,29 @@ pub async fn interactive_session( if let Some(maybe_path) = file_path { let default_out_dir = PathBuf::from(&config.defaults.out_dir); - let file_path = match maybe_path { - Some(path) => { - // If given an absolute path - override the `default_out_dir` - if path.is_absolute() { - let parent = path.parent().unwrap_or(&default_out_dir); - create_recursive!(parent); - path - } else { - let joined_path = default_out_dir.join(&path); - let parent_path = joined_path.parent().expect("Does not have root"); - create_recursive!(parent_path); - joined_path - } - } - None => { - let default_out_dir = PathBuf::from(&config.defaults.out_dir); - compat_port_path!(default_out_dir, port_name) + let file_path = if let Some(path) = maybe_path { + // If given an absolute path - override the `default_out_dir` + if path.is_absolute() { + let parent = path.parent().unwrap_or(&default_out_dir); + create_recursive!(parent); + path + } else { + let joined_path = default_out_dir.join(&path); + let parent_path = joined_path.parent().expect("Does not have root"); + create_recursive!(parent_path); + joined_path } + } else { + let default_out_dir = PathBuf::from(&config.defaults.out_dir); + compat_port_path!(default_out_dir, port_name) }; + let file_rx = broadcast_event_tx.subscribe(); tasks.spawn(async move { run_file_output(file_rx, file_path.clone()).await; run_file_exit_script(config, file_path); }); - }; + } if debug { let debug_rx = broadcast_event_tx.subscribe(); @@ -278,7 +276,7 @@ pub fn get_settings(baud: u32, port: &str) -> miette::Result<()> { /// Prints a list of available serial ports to stdout. /// /// Ultimately a wrapper around [`SerialPort::available_ports()`] and may error -/// if it is called on an unsupported platform as per [`SerialPort::available_ports()]s docs +/// if it is called on an unsupported platform as per [`SerialPort::available_ports()`]s docs pub fn list_serial_ports() -> miette::Result<()> { let mut stdout = io::stdout(); let ports = map_miette!( diff --git a/sericom-core/src/configs/appearance.rs b/sericom-core/src/configs/appearance.rs index 2d66238..5638f09 100644 --- a/sericom-core/src/configs/appearance.rs +++ b/sericom-core/src/configs/appearance.rs @@ -11,7 +11,7 @@ use std::borrow::Cow; /// fg = "green" /// bg = "none" /// ``` -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Debug, Deserialize, PartialEq, Eq)] pub struct Appearance { #[serde(default = "default_fg")] pub fg: SeriColor, @@ -19,10 +19,10 @@ pub struct Appearance { pub bg: SeriColor, } -fn default_fg() -> SeriColor { +const fn default_fg() -> SeriColor { SeriColor::Green } -fn default_bg() -> SeriColor { +const fn default_bg() -> SeriColor { SeriColor::None } @@ -128,23 +128,23 @@ impl SeriColor { let normalized_cow = normalizer(input_slice); let normalized_str = normalized_cow.as_ref(); match normalized_str { - "black" => Ok(SeriColor::Black), - "blue" => Ok(SeriColor::Blue), - "cyan" => Ok(SeriColor::Cyan), - "darkblue" => Ok(SeriColor::DarkBlue), - "darkcyan" => Ok(SeriColor::DarkCyan), - "darkgreen" => Ok(SeriColor::DarkGreen), - "darkgrey" | "darkgray" => Ok(SeriColor::DarkGrey), - "darkmagenta" => Ok(SeriColor::DarkMagenta), - "darkred" => Ok(SeriColor::DarkRed), - "darkyellow" => Ok(SeriColor::DarkYellow), - "default" => Ok(SeriColor::None), - "green" => Ok(SeriColor::Green), - "grey" | "gray" => Ok(SeriColor::Grey), - "magenta" => Ok(SeriColor::Magenta), - "red" => Ok(SeriColor::Red), - "white" => Ok(SeriColor::White), - "yellow" => Ok(SeriColor::Yellow), + "black" => Ok(Self::Black), + "blue" => Ok(Self::Blue), + "cyan" => Ok(Self::Cyan), + "darkblue" => Ok(Self::DarkBlue), + "darkcyan" => Ok(Self::DarkCyan), + "darkgreen" => Ok(Self::DarkGreen), + "darkgrey" | "darkgray" => Ok(Self::DarkGrey), + "darkmagenta" => Ok(Self::DarkMagenta), + "darkred" => Ok(Self::DarkRed), + "darkyellow" => Ok(Self::DarkYellow), + "default" => Ok(Self::None), + "green" => Ok(Self::Green), + "grey" | "gray" => Ok(Self::Grey), + "magenta" => Ok(Self::Magenta), + "red" => Ok(Self::Red), + "white" => Ok(Self::White), + "yellow" => Ok(Self::Yellow), _ => Err(VALID_SERICOLORS), } } @@ -156,7 +156,7 @@ impl<'de> Deserialize<'de> for SeriColor { D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; - match SeriColor::parse_from_str(&s, NORMALIZER) { + match Self::parse_from_str(&s, NORMALIZER) { Ok(s) => Ok(s), Err(valid_colors) => Err(serde::de::Error::unknown_variant(&s, valid_colors)), } @@ -164,25 +164,25 @@ impl<'de> Deserialize<'de> for SeriColor { } impl From<&SeriColor> for crossterm::style::Color { - fn from(value: &SeriColor) -> crossterm::style::Color { + fn from(value: &SeriColor) -> Self { match value { - SeriColor::Black => crossterm::style::Color::Black, - SeriColor::Blue => crossterm::style::Color::Blue, - SeriColor::Cyan => crossterm::style::Color::Cyan, - SeriColor::DarkBlue => crossterm::style::Color::DarkBlue, - SeriColor::DarkCyan => crossterm::style::Color::DarkCyan, - SeriColor::DarkGreen => crossterm::style::Color::DarkGreen, - SeriColor::DarkGrey => crossterm::style::Color::DarkGrey, - SeriColor::DarkMagenta => crossterm::style::Color::DarkMagenta, - SeriColor::DarkRed => crossterm::style::Color::DarkRed, - SeriColor::DarkYellow => crossterm::style::Color::DarkYellow, - SeriColor::Green => crossterm::style::Color::Green, - SeriColor::Grey => crossterm::style::Color::Grey, - SeriColor::Magenta => crossterm::style::Color::Magenta, - SeriColor::None => crossterm::style::Color::Reset, - SeriColor::Red => crossterm::style::Color::Red, - SeriColor::White => crossterm::style::Color::White, - SeriColor::Yellow => crossterm::style::Color::Yellow, + SeriColor::Black => Self::Black, + SeriColor::Blue => Self::Blue, + SeriColor::Cyan => Self::Cyan, + SeriColor::DarkBlue => Self::DarkBlue, + SeriColor::DarkCyan => Self::DarkCyan, + SeriColor::DarkGreen => Self::DarkGreen, + SeriColor::DarkGrey => Self::DarkGrey, + SeriColor::DarkMagenta => Self::DarkMagenta, + SeriColor::DarkRed => Self::DarkRed, + SeriColor::DarkYellow => Self::DarkYellow, + SeriColor::Green => Self::Green, + SeriColor::Grey => Self::Grey, + SeriColor::Magenta => Self::Magenta, + SeriColor::None => Self::Reset, + SeriColor::Red => Self::Red, + SeriColor::White => Self::White, + SeriColor::Yellow => Self::Yellow, } } } diff --git a/sericom-core/src/configs/defaults.rs b/sericom-core/src/configs/defaults.rs index 311f09e..e1259c9 100644 --- a/sericom-core/src/configs/defaults.rs +++ b/sericom-core/src/configs/defaults.rs @@ -2,6 +2,7 @@ use crate::path_utils::{ExpandPaths, is_executable}; use serde::{Deserialize, Deserializer}; use std::path::PathBuf; +#[allow(clippy::doc_markdown)] /// Represents the `[defaults]` table of the `config.toml` file. /// /// The `[defaults]` table holds configuration values for how sericom @@ -9,7 +10,7 @@ use std::path::PathBuf; /// where files will be created when running `sericom -f path/to/file [PORT]`. /// /// The default values (if no config exists) is the current directory. Uses -/// [`current_dir`] which corresponds to getcwd on Unix and GetCurrentDirectoryW +/// [`current_dir`] which corresponds to getcwd on Unix and GetCurrentDirectory /// on Windows. If this fails it simply uses "./". /// /// ```toml @@ -21,7 +22,7 @@ use std::path::PathBuf; /// ``` /// /// [`current_dir`]: std::env::current_dir() -#[derive(Debug, Deserialize, PartialEq)] +#[derive(Debug, Deserialize, Eq, PartialEq)] pub struct Defaults { #[serde(rename = "out-dir")] #[serde(default = "default_out_dir")] @@ -51,7 +52,7 @@ impl Default for Defaults { fn default_out_dir() -> PathBuf { use std::env::current_dir; - current_dir().unwrap_or(PathBuf::from("./")) + current_dir().unwrap_or_else(|_| PathBuf::from("./")) } fn validate_dir<'de, D>(deserializer: D) -> Result @@ -62,7 +63,7 @@ where let p = PathBuf::deserialize(deserializer)? .get_expanded_path() - .ok_or(Error::custom("Error expanding path."))?; + .ok_or_else(|| Error::custom("Error expanding path."))?; if !p.exists() || !p.is_dir() { return Err(serde::de::Error::custom( "Error setting out-dir, Either does not exist or is not a directory", @@ -79,7 +80,7 @@ where let p = PathBuf::deserialize(deserializer)? .get_expanded_path() - .ok_or(Error::custom("Error expanding path."))?; + .ok_or_else(|| Error::custom("Error expanding path."))?; if !p.exists() || !p.is_file() { return Err(serde::de::Error::custom( "Error retrieving file, Either does not exist or is not a file", diff --git a/sericom-core/src/debug.rs b/sericom-core/src/debug.rs index 6947ce3..9a552ad 100644 --- a/sericom-core/src/debug.rs +++ b/sericom-core/src/debug.rs @@ -6,6 +6,7 @@ use crate::serial_actor::SerialEvent; /// This function is used for debugging the data that is sent from a device. +/// /// It will create a file "debug.txt" and print the data received from the device /// as the actual bytes received along with the corresponding ascii characters. /// @@ -34,21 +35,6 @@ pub async fn run_debug_output(mut rx: tokio::sync::broadcast::Receiver = data[..std::cmp::min(20, data.len())] - // .iter() - // .filter(|b| b.is_ascii_control()) - // .cloned() - // .collect(); - // Only prints the bytes of ASCII escape characters - // writeln!( - // writer, - // "RX {} bytes: {:02X?}{} UTF8: {}", - // data.len(), - // control_bytes_for_hex, - // if data.len() > 20 { "..." } else { "" }, - // String::from_utf8_lossy(&data) - // ) - // .ok(); // Prints bytes of all characters writeln!( writer, @@ -88,7 +74,6 @@ pub async fn run_debug_output(mut rx: tokio::sync::broadcast::Receiver { eprintln!("File writer lagged, skipped {skipped} messages"); - continue; // Don't break on lag } _ => break, } diff --git a/sericom-core/src/lib.rs b/sericom-core/src/lib.rs index 7748dac..9556c28 100644 --- a/sericom-core/src/lib.rs +++ b/sericom-core/src/lib.rs @@ -8,6 +8,8 @@ //! create an [issue](https://github.com/tkatter/sericom) so I can become aware and work //! towards making `sericom-core` a generalized/compatible library that is better suited //! for use among other crates. +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::missing_panics_doc)] pub mod cli; pub mod configs; diff --git a/sericom-core/src/screen/buffer.rs b/sericom-core/src/screen/buffer.rs index bab7d99..860ed1a 100644 --- a/sericom-core/src/screen/buffer.rs +++ b/sericom-core/src/screen/buffer.rs @@ -1,6 +1,9 @@ -use crate::ui::{self, Cursor, Line, Rect, TermPos}; +use crossterm::style::Attributes; use std::collections::VecDeque; +use super::position::{Position, TermPos, TranslatePos}; +use super::{Line, Rect}; + /// The maximum number of lines stored in memory in [`ScreenBuffer`]. pub const MAX_SCROLLBACK: usize = 10000; @@ -19,11 +22,9 @@ pub struct ScreenBuffer { /// The terminal's dimensions pub(crate) rect: Rect, /// Position of the cursor within the `ScreenBuffer`. - pub(crate) cursor: ui::Position, + pub(crate) cursor: Position, /// Start of text selection. Used for highlighting and copying to clipboard. selection_start: Option<(u16, usize)>, - /// Saved cursor position from ascii escape sequence - pub(crate) saved_cursor: Option>, /// End of text selection. Used for highlighting and copying to clipboard. selection_end: Option<(u16, usize)>, /// Configuration for the maximum amount of lines to keep in memory. @@ -34,13 +35,13 @@ impl ScreenBuffer { /// Constructs a new `ScreenBuffer`. /// /// Takes the `width` and `height` of the terminal. + #[must_use] pub fn new(rect: Rect) -> Self { let mut buffer = Self { lines: VecDeque::new(), view_start: 0, rect, - cursor: ui::Position::ORIGIN, - saved_cursor: None, + cursor: Position::ORIGIN, selection_start: None, selection_end: None, max_scrollback: MAX_SCROLLBACK, @@ -50,121 +51,153 @@ impl ScreenBuffer { buffer } - const fn width(&self) -> u16 { + pub(crate) const fn width(&self) -> u16 { self.rect.width } - pub(crate) fn cursor_is_at_end(&self) -> bool { - if self.lines.is_empty() { - return self.cursor.x == 0 && self.cursor.y == 0; - } - - let bottom_window_line = self.view_start + usize::from(self.rect.height); - let final_line = self.lines.len(); - let line_relative_pos = final_line - self.view_start; - - if final_line > bottom_window_line { - false - } else { - let last_cell_pos = self - .lines - .get(final_line - 1) - .map_or_else(|| 0, |line| line.last_cell_idx()); - self.cursor.x as usize == last_cell_pos && self.cursor.y as usize == line_relative_pos - } + pub(crate) fn push_line(&mut self, line: Line) { + self.lines.push_back(line); } - pub(crate) fn line_from_cursor(&mut self) -> usize { - let line_idx = self.view_start + usize::from(self.cursor.y); - while line_idx > self.lines.len() { - self.lines - .push_back(Line::new_empty(usize::from(self.rect.width))); - } - line_idx + pub fn save_cursor_pos(&mut self, stdout: &mut W) { + use crossterm::cursor::SavePosition; + crossterm::execute!(stdout, SavePosition); } - fn set_char_at_cursor(&mut self, ch: char) { - while usize::from(self.cursor.y) >= self.lines.len() { - self.lines - .push_back(Line::new_default(usize::from(self.width()))); - } - - if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) - && (self.cursor.x as usize) < line.len() - { - // line.set_char(self.cursor.x as usize, ch); - } + pub fn restore_cursor_pos(&mut self, stdout: &mut W) { + use crossterm::cursor::RestorePosition; + crossterm::execute!(stdout, RestorePosition); } - fn clear_from_cursor_to_sol(&mut self) { - if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { - // line.reset_to(self.cursor.x as usize); - } - } + pub(crate) fn handle_span_colors(&mut self, colors: &super::ColorState, attrs: Attributes) { + let curr_col = self.cursor.x as usize; + let width = self.width() as usize; - fn clear_from_cursor_to_sos(&mut self) { - self.clear_from_cursor_to_sol(); - for line in self - .lines - .range_mut(self.view_start..usize::from(self.cursor.y)) - { - line.reset(); - } - } + let line = self.curr_line_mut(); + let remainder = width - line.num_cells(); - fn clear_from_cursor_to_eol(&mut self) { - if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { - // line.reset_from(self.cursor.x as usize); - } + line.split_spans(colors, attrs, curr_col, remainder); } - fn clear_from_cursor_to_eos(&mut self) { - self.clear_from_cursor_to_eol(); - for line in self.lines.range_mut(usize::from(self.cursor.y) + 1..) { - line.reset(); - } - } + pub(crate) fn with_current_span(&mut self, f: F) { + let buff_pos = self.to_buff(self.cursor); - fn clear_whole_line(&mut self) { - if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { - line.reset(); - } - } + let span = { + let line = self.curr_line_mut(); + let (span_idx, _col_offset) = line.span_at_col(buff_pos.x as usize); + line.get_mut_span(span_idx).expect("verified") + }; - fn new_line(&mut self) { - // TODO: F*X THIS - // self.set_cursor_pos((0, self.cursor.y + 1)); + f(span); + } - if usize::from(self.cursor.y) >= self.lines.len() { - self.lines - .push_back(Line::new_default(usize::from(self.width()))); - } + pub(crate) fn with_current_line(&mut self, f: F) { + let buff_pos = self.to_buff(self.cursor); - // Remove old lines if exceeding `ScreenBuffer.max_scrollback` - while self.lines.len() > self.max_scrollback { - self.lines.pop_front(); - // Update the view position - if self.cursor.y > 0 { - self.cursor.y -= 1; - } - if self.view_start > 0 { - self.view_start -= 1; - } - } - } + let line = self.curr_line_mut(); - pub(crate) fn push_line(&mut self, curr_line: Line) { - self.lines.push_back(curr_line); + f(line); } - pub const fn save_cursor_pos(&mut self) { - self.saved_cursor = Some(self.cursor); + pub(crate) fn curr_line(&mut self) -> &Line { + let pos_in_lines = self.to_buff(self.cursor); + if self.lines.get(pos_in_lines.y as usize).is_some() { + self.lines + .get(pos_in_lines.y as usize) + .expect("verified that line exists") + } else { + self.push_line(Line::reserve_new(self.width() as usize)); + self.lines.back().expect("is not empty") + } } - pub const fn restore_cursor_pos(&mut self) { - if let Some(saved_cursor) = self.saved_cursor { - self.cursor = saved_cursor; - self.saved_cursor = None; + pub(crate) fn curr_line_mut(&mut self) -> &mut Line { + let pos_in_lines = self.to_buff(self.cursor); + if self.lines.get(pos_in_lines.y as usize).is_some() { + self.lines + .get_mut(pos_in_lines.y as usize) + .expect("verified that line exists") + } else { + self.push_line(Line::reserve_new(self.width() as usize)); + self.lines.back_mut().expect("is not empty") } } + // fn clear_from_cursor_to_sol(&mut self) { + // if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { + // // line.reset_to(self.cursor.x as usize); + // } + // } + + // fn clear_from_cursor_to_sos(&mut self) { + // self.clear_from_cursor_to_sol(); + // for line in self + // .lines + // .range_mut(self.view_start..usize::from(self.cursor.y)) + // { + // line.reset(); + // } + // } + + // fn clear_from_cursor_to_eol(&mut self) { + // if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { + // // line.reset_from(self.cursor.x as usize); + // } + // } + + // fn clear_from_cursor_to_eos(&mut self) { + // self.clear_from_cursor_to_eol(); + // for line in self.lines.range_mut(usize::from(self.cursor.y) + 1..) { + // line.reset(); + // } + // } + + // fn clear_whole_line(&mut self) { + // if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { + // line.reset(); + // } + // } + + // fn new_line(&mut self) { + // // TODO: F*X THIS + // // self.set_cursor_pos((0, self.cursor.y + 1)); + // + // if usize::from(self.cursor.y) >= self.lines.len() { + // self.lines + // .push_back(Line::new_default(usize::from(self.width()))); + // } + // + // // Remove old lines if exceeding `ScreenBuffer.max_scrollback` + // while self.lines.len() > self.max_scrollback { + // self.lines.pop_front(); + // // Update the view position + // if self.cursor.y > 0 { + // self.cursor.y -= 1; + // } + // if self.view_start > 0 { + // self.view_start -= 1; + // } + // } + // } + + // fn set_char_at_cursor(&mut self, ch: char) { + // while usize::from(self.cursor.y) >= self.lines.len() { + // self.lines + // .push_back(Line::new_default(usize::from(self.width()))); + // } + // + // if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) + // && (self.cursor.x as usize) < line.len() + // { + // // line.set_char(self.cursor.x as usize, ch); + // } + // } + + // pub(crate) fn line_from_cursor(&mut self) -> usize { + // let line_idx = self.view_start + usize::from(self.cursor.y); + // while line_idx > self.lines.len() { + // self.lines + // .push_back(Line::new_empty(usize::from(self.rect.width))); + // } + // line_idx + // } } diff --git a/sericom-core/src/screen/components/cell.rs b/sericom-core/src/screen/components/cell.rs index c9945cc..ded64d3 100644 --- a/sericom-core/src/screen/components/cell.rs +++ b/sericom-core/src/screen/components/cell.rs @@ -14,6 +14,7 @@ pub struct Cell { impl Cell { pub const EMPTY: Self = Self::new(' '); + pub const TAB: Self = Self::new('\t'); #[must_use] pub const fn new(character: char) -> Self { diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index 3bb8743..a0c2692 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -1,7 +1,11 @@ use std::ops::{Index, IndexMut}; +use crossterm::style::Attributes; + +use crate::screen::ColorState; + +use super::Cell; use super::Span; -use crate::ui::Cell; /// Line is a wrapper around [`Vec`] and represents a line within the [`ScreenBuffer`][`super::ScreenBuffer`]. #[derive(Debug, Clone, Eq, PartialEq, Default)] @@ -37,9 +41,6 @@ impl Line { /// /// [`Cell`]: crate::ui::Cell #[must_use] - // pub fn reserve_new(spans: usize) -> Self { - // Self(Vec::with_capacity(spans)) - // } pub fn reserve_new(width: usize) -> Self { Self(vec![Span::reserve_new(width, None, None); 1]) } @@ -88,6 +89,7 @@ impl Line { } /// Returns a reference to [`Span`] at `idx`. + #[must_use] pub fn get_span(&self, idx: usize) -> Option<&Span> { self.0.get(idx) } @@ -98,6 +100,7 @@ impl Line { } /// Returns the index of the [`Span`] and the offset within the [`Span`] for `col` + #[must_use] pub fn span_at_col(&self, col: usize) -> (usize, usize) { if self.0.len() == 1 { return (0, col); @@ -114,6 +117,8 @@ impl Line { (self.0.len().saturating_sub(1), self.0.last().unwrap().len()) } + /// The total number of [`Cell`]s, for all [`Span`]s in [`Line`], where the [`Cell`] != [`Cell::EMPTY`]. + #[must_use] pub fn num_filled_cells(&self) -> usize { let mut filled_cells: usize = 0; self.0.iter().for_each(|span| { @@ -122,6 +127,8 @@ impl Line { filled_cells } + /// The total number of [`Cell`]s for all [`Span`]s in [`Line`]. + #[must_use] pub fn num_cells(&self) -> usize { let mut num_cells = 0; self.0.iter().for_each(|span| { @@ -130,6 +137,8 @@ impl Line { num_cells } + /// Whether [`Line`] contains zero _[`Span`]s_. + #[must_use] pub const fn is_empty(&self) -> bool { self.0.is_empty() } @@ -146,20 +155,46 @@ impl Line { self.0.push(span); } - /// Returns the index of the last cell within a line where the [`Cell`] != [`Cell::EMPTY`] - pub fn last_cell_idx(&self) -> usize { - if self.0.is_empty() { - return 0; + // /// Returns the index of the last cell within a line where the [`Cell`] != [`Cell::EMPTY`] + // pub fn last_cell_idx(&self) -> usize { + // if self.0.is_empty() { + // return 0; + // } + // let mut offset = self.0.iter().map(|s| s.cells.len()).sum::(); + // for span in self.0.iter().rev() { + // offset -= span.cells.len(); + // if let Some(pos) = span.cells.iter().rposition(|c| *c != Cell::EMPTY) { + // return offset + pos; + // } + // } + // 0 + // } + + /// Shrinks the span at `col` to `span.len()` and creates a new span with + /// capacity of `fill_to`, `colors` and `attrs`. See [`Span::reserve_new`]. + pub fn split_spans( + &mut self, + colors: &ColorState, + attrs: Attributes, + col: usize, + fill_to: usize, + ) { + // Handles the case where an ESC[ is the first input for an empty line + if self.len() == 1 && self.num_cells() == 0 { + let mut span = self.get_mut_span(0).expect("verified line.len() == 1"); + span.set_colors(colors); + span.set_attrs(attrs); + return; } - let mut offset = self.0.iter().map(|s| s.cells.len()).sum::(); - for span in self.0.iter().rev() { - offset -= span.cells.len(); - if let Some(pos) = span.cells.iter().rposition(|c| *c != Cell::EMPTY) { - return offset + pos; - } + let (span_idx, _) = self.span_at_col(col); + match self.get_mut_span(span_idx) { + Some(mut span) => span.shrink(), + None => self.push(Span::new_empty(fill_to)), } - 0 + + let span = Span::reserve_new(fill_to, Some(colors.get_colors()), Some(attrs)); + self.push(span); } } @@ -203,85 +238,84 @@ impl IndexMut for Line { } } -#[cfg(test)] -mod tests { - use super::*; - use crate::ui::Cell; - - #[test] - fn empty_single_span() { - let span1 = Span::new_empty(80); - let line1 = Line(vec![span1]); - - assert_eq!(line1.last_cell_idx(), 0); - } - - #[test] - fn empty_multi_span() { - let span1 = Span::new_empty(20); - let span2 = Span::new_empty(20); - let span3 = Span::new_empty(20); - let span4 = Span::new_empty(20); - let line1 = Line(vec![span1, span2, span3, span4]); - - assert_eq!(line1.last_cell_idx(), 0); - } - - #[test] - fn middle_span_filled() { - let span1 = Span::new_empty(20); - let span2 = Span::new_empty(20); - let mut span3 = Span::new_empty(20); - let span4 = Span::new_empty(20); - - let cell = Cell { - character: 'a', - is_selected: false, - }; - - *span3.cells.get_mut(10).unwrap() = cell.clone(); - - let line1 = Line(vec![span1, span2, span3, span4]); - - assert_eq!(line1.last_cell_idx(), 50); - assert_eq!(line1.get_span(2).unwrap().cells.get(10).unwrap(), &cell); - } - - #[test] - fn last_span_filled() { - let span1 = Span::new_empty(20); - let span2 = Span::new_empty(20); - let mut span3 = Span::new_empty(20); - - let cell = Cell { - character: 'a', - is_selected: false, - }; - - *span3.cells.get_mut(10).unwrap() = cell.clone(); - - let line1 = Line(vec![span1, span2, span3]); - - assert_eq!(line1.last_cell_idx(), 50); - assert_eq!(line1.get_span(2).unwrap().cells.get(10).unwrap(), &cell); - } - - #[test] - fn first_span_filled() { - let mut span1 = Span::new_empty(20); - let span2 = Span::new_empty(20); - let span3 = Span::new_empty(20); - - let cell = Cell { - character: 'a', - is_selected: false, - }; - - *span1.cells.get_mut(10).unwrap() = cell.clone(); - - let line1 = Line(vec![span1, span2, span3]); - - assert_eq!(line1.last_cell_idx(), 10); - assert_eq!(line1.get_span(0).unwrap().cells.get(10).unwrap(), &cell); - } -} +// #[cfg(test)] +// mod tests { +// use super::*; +// +// #[test] +// fn empty_single_span() { +// let span1 = Span::new_empty(80); +// let line1 = Line(vec![span1; 1]); +// +// assert_eq!(line1.last_cell_idx(), 0); +// } +// +// #[test] +// fn empty_multi_span() { +// let span1 = Span::new_empty(20); +// let span2 = Span::new_empty(20); +// let span3 = Span::new_empty(20); +// let span4 = Span::new_empty(20); +// let line1 = Line(vec![span1, span2, span3, span4]); +// +// assert_eq!(line1.last_cell_idx(), 0); +// } +// +// #[test] +// fn middle_span_filled() { +// let span1 = Span::new_empty(20); +// let span2 = Span::new_empty(20); +// let mut span3 = Span::new_empty(20); +// let span4 = Span::new_empty(20); +// +// let cell = Cell { +// character: 'a', +// is_selected: false, +// }; +// +// *span3.cells.get_mut(10).unwrap() = cell.clone(); +// +// let line1 = Line(vec![span1, span2, span3, span4]); +// +// assert_eq!(line1.last_cell_idx(), 50); +// assert_eq!(line1.get_span(2).unwrap().cells.get(10).unwrap(), &cell); +// } +// +// #[test] +// fn last_span_filled() { +// let span1 = Span::new_empty(20); +// let span2 = Span::new_empty(20); +// let mut span3 = Span::new_empty(20); +// +// let cell = Cell { +// character: 'a', +// is_selected: false, +// }; +// +// *span3.cells.get_mut(10).unwrap() = cell.clone(); +// +// let line1 = Line(vec![span1, span2, span3]); +// +// assert_eq!(line1.last_cell_idx(), 50); +// assert_eq!(line1.get_span(2).unwrap().cells.get(10).unwrap(), &cell); +// } +// +// #[test] +// fn first_span_filled() { +// let mut span1 = Span::new_empty(20); +// let span2 = Span::new_empty(20); +// let span3 = Span::new_empty(20); +// +// let cell = Cell { +// character: 'a', +// is_selected: false, +// }; +// +// *span1.cells.get_mut(10).unwrap() = cell.clone(); +// +// let line1 = Line(vec![span1, span2, span3]); +// +// assert_eq!(line1.last_cell_idx(), 10); +// assert_eq!(line1.get_span(0).unwrap().cells.get(10).unwrap(), &cell); +// } +// } diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index 4a38e37..d71f9b5 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -2,7 +2,7 @@ use std::ops::{Index, IndexMut}; use crossterm::style::{Attribute, Attributes, Color, Colors}; -use crate::{configs::get_config, screen::process::ColorState, ui::Cell}; +use crate::{configs::get_config, screen::Cell, screen::process::ColorState}; #[derive(Debug, Clone, Eq, PartialEq)] pub struct Span { @@ -23,13 +23,22 @@ impl Default for Span { } impl Span { + pub(crate) fn tab() -> Self { + let colors = Self::get_config_colors(); + Self { + cells: vec![Cell::TAB; 1], + attrs: Attributes::default(), + colors, + } + } + fn get_config_colors() -> Colors { let config = get_config(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); Colors::new(fg, bg) } - pub(crate) fn set_attrs(&mut self, attrs: Attributes) { + pub(crate) const fn set_attrs(&mut self, attrs: Attributes) { self.attrs = attrs; } pub(crate) fn add_attr(&mut self, attr: Attribute) { @@ -78,13 +87,13 @@ impl Span { pub(crate) fn push(&mut self, cell: Cell) { self.cells.push(cell); } - pub(crate) fn len(&self) -> usize { + pub(crate) const fn len(&self) -> usize { self.cells.len() } - pub(crate) fn set_colors(&mut self, colors: &ColorState) { + pub(crate) const fn set_colors(&mut self, colors: &ColorState) { self.colors = colors.get_colors(); } - pub(crate) fn is_empty(&self) -> bool { + pub(crate) const fn is_empty(&self) -> bool { self.cells.is_empty() } pub fn iter(&self) -> std::slice::Iter<'_, Cell> { @@ -94,6 +103,7 @@ impl Span { self.cells.iter_mut() } /// Returns the number of [`Cell`]s in a [`Span`] that are not [`Cell::EMPTY`] + #[must_use] pub fn num_filled_cells(&self) -> usize { self.cells .iter() diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/driver.rs index d835dc5..bc186a6 100644 --- a/sericom-core/src/screen/driver.rs +++ b/sericom-core/src/screen/driver.rs @@ -1,22 +1,27 @@ use crossterm::style::Attributes; +use tracing::Instrument; -use crate::screen::ScreenBuffer; -use crate::ui::{BK, BS, CR, ESC, FF, Line, NL, TAB}; -use crate::ui::{Cell, ColorState, Cursor, ParserEvent, Span, TranslatePos}; -use crate::ui::{process_colors, process_cursor}; +use super::ScreenBuffer; +use super::components::{Cell, Line, Span}; +use super::position::{Cursor, TranslatePos}; +use super::process::{BK, BS, CR, ColorState, ESC, FF, NL, ParserEvent, TAB}; +use super::{process_colors, process_cursor, process_erase, process_screen}; -pub struct ScreenDriver<'a> { +/// The layer between incoming [`ParserEvent`]s and the [`ScreenBuffer`]. +pub struct ScreenDriver<'a, W: std::io::Write> { buffer: &'a mut ScreenBuffer, color_state: ColorState, attrs: Attributes, + stdout: &'a mut W, } -impl<'a> ScreenDriver<'a> { - pub fn new(buffer: &'a mut ScreenBuffer) -> Self { +impl<'a, W: std::io::Write> ScreenDriver<'a, W> { + pub fn new(buffer: &'a mut ScreenBuffer, stdout: &'a mut W) -> Self { Self { buffer, color_state: ColorState::default(), attrs: Attributes::default(), + stdout, } } @@ -28,9 +33,10 @@ impl<'a> ScreenDriver<'a> { ParserEvent::EscapeSequence(seq) => self.handle_escape(&seq), } } - todo!() } + // TODO: HANDLE CHECKING TO SEE IF THE CURSOR IS OVER AN EXISTING + // SPAN/LINE BEFORE WRITING - IF SO, NEED TO SPLIT APPROPRIATLY fn write_text(&mut self, bytes: &[u8]) { let chars: Vec = bytes.iter().map(|b| char::from(*b)).collect(); let num_chars = chars.len(); @@ -40,27 +46,40 @@ impl<'a> ScreenDriver<'a> { span.push(Cell::new(ch)); } }); + + // Truncating is unlikely to happen in this scenario, but even so, + // `move_cursor_right` will clamp to `self.width` so its ok. + #[allow(clippy::cast_possible_truncation)] self.buffer.move_cursor_right(num_chars as u16); } fn handle_control(&mut self, ctrl: u8) { match ctrl { BS => self.buffer.move_cursor_left(1), - CR => self.buffer.set_cursor_col(0), - // Need to handle creating new empty buffer - FF => todo!(), - // FF => queue!(writer, Clear(ClearType::All)).into_diagnostic()?, NL => { let remainder = - self.buffer.rect.width as usize - (self.buffer.curr_line().num_cells()); + self.buffer.width() as usize - (self.buffer.curr_line().num_cells()); if remainder != 0 { self.buffer.with_current_span(|span| { - span.fill_to_width(remainder); + let width = remainder + span.cells.len(); + span.fill_to_width(width); }); } + self.buffer + .push_line(Line::reserve_new(self.buffer.width() as usize)); + self.buffer.set_cursor_col(0); self.buffer.move_cursor_down(1); } - TAB => todo!(), + TAB => self.buffer.with_current_span(|span| { + span.push(Cell::TAB); + }), + // Need to make the events peekable to handle '\r\n' for now + // lets just roll with ignoring it and see what happens + #[allow(clippy::match_same_arms)] + CR => {} // self.buffer.set_cursor_col(0), + // Not sure that FF needs to be handled + #[allow(clippy::match_same_arms)] + FF => {} _ => {} } } @@ -71,77 +90,33 @@ impl<'a> ScreenDriver<'a> { }; match seq_type { EscSequenceType::Cursor(kind) => { - // TODO: Figure out span-splitting - process_cursor(seq, kind, self.buffer); + process_cursor(seq, kind, self.buffer, self.stdout); } - EscSequenceType::Erase(_kind) => todo!(), + EscSequenceType::Erase(kind) => process_erase(seq, kind, self.buffer), EscSequenceType::Graphics => { process_colors(seq, &mut self.color_state, &mut self.attrs); - self.buffer.with_current_span(|span| { - if !span.is_empty() { - span.shrink(); - // self.curr_line.push(curr_span); - // curr_span = Span::reserve_new( - // span_cap(&self.curr_line, &self.rect), - // None, - // None, - // ); - } - span.set_attrs(self.attrs); - span.set_colors(&self.color_state); - }); + self.buffer + .handle_span_colors(&self.color_state, self.attrs); } - EscSequenceType::Screen(_kind) => todo!(), - } - } -} - -impl ScreenBuffer { - fn with_current_span(&mut self, f: F) { - let buff_pos = self.to_buff(self.cursor); - - let span = { - let line = self.curr_line_mut(); - let (span_idx, _col_offset) = line.span_at_col(buff_pos.x as usize); - line.get_mut_span(span_idx).expect("verified") - }; - - f(span); - } - - fn curr_line(&mut self) -> &Line { - let pos_in_lines = self.to_buff(self.cursor); - if self.lines.get(pos_in_lines.y as usize).is_some() { - self.lines - .get(pos_in_lines.y as usize) - .expect("verified that line exists") - } else { - self.push_line(Line::reserve_new(self.rect.width as usize)); - self.lines.back().expect("is not empty") - } - } - - fn curr_line_mut(&mut self) -> &mut Line { - let pos_in_lines = self.to_buff(self.cursor); - if self.lines.get(pos_in_lines.y as usize).is_some() { - self.lines - .get_mut(pos_in_lines.y as usize) - .expect("verified that line exists") - } else { - self.push_line(Line::reserve_new(self.rect.width as usize)); - self.lines.back_mut().expect("is not empty") + EscSequenceType::Screen(_kind) => {} // process_screen(seq, kind, self.buffer), } } } -enum EscSequenceType { +/// The category of escape sequence determined by the last char of the sequence. +pub enum EscSequenceType { + /// Control cursor movement Cursor(u8), + /// Sequences that clear the screen Erase(u8), + /// Color changes/modes Graphics, + /// Set screen modes Screen(u8), } fn classify_escape_seq(seq: &[u8]) -> Option { + // https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 // Ensures the sequence resembles: ESC[ if seq.len() < 3 || seq[0] != ESC || seq[1] != BK { return None; diff --git a/sericom-core/src/screen/mod.rs b/sericom-core/src/screen/mod.rs index c06d2c2..19fd23d 100644 --- a/sericom-core/src/screen/mod.rs +++ b/sericom-core/src/screen/mod.rs @@ -16,6 +16,7 @@ //! connection in a [`VecDeque`]. It is important to note that //! currently, the **capacity of the [`VecDeque`] is hardcoded with a value of 10,000 //! lines with [`MAX_SCROLLBACK`]**. +#![deny(dead_code)] #![allow(unused)] mod buffer; @@ -30,8 +31,10 @@ mod ui_command; pub use buffer::ScreenBuffer; pub use components::{Cell, Line, Span}; pub use position::{BuffPos, Cursor, PosType, PosY, Position, Scope, TermPos, TranslatePos}; -pub use process::{ - ByteParser, ColorState, ParseState, ParserEvent, process_colors, process_cursor, -}; +pub use process::{ByteParser, ColorState, ParseState, ParserEvent}; pub use rect::Rect; pub use ui_command::{UIAction, UICommand}; + +pub(in crate::screen) use process::{ + process_colors, process_cursor, process_erase, process_screen, +}; diff --git a/sericom-core/src/screen/position.rs b/sericom-core/src/screen/position.rs index b009e6c..338f325 100644 --- a/sericom-core/src/screen/position.rs +++ b/sericom-core/src/screen/position.rs @@ -50,24 +50,24 @@ impl Position { } impl Position { - pub fn set_y(&mut self, y: PosY) { + pub const fn set_y(&mut self, y: PosY) { self.y = y; } - pub fn set_x(&mut self, x: u16) { + pub const fn set_x(&mut self, x: u16) { self.x = x; } - pub fn set_pos_from>>(&mut self, pos: P) { + pub fn set_pos_from>(&mut self, pos: P) { let p = pos.into(); self.x = p.x; self.y = p.y; } - pub fn set(&mut self, pos: Self) { + pub const fn set(&mut self, pos: Self) { *self = pos; } - pub fn x(&self) -> u16 { + pub const fn x(&self) -> u16 { self.x } - pub fn y(&self) -> PosY { + pub const fn y(&self) -> PosY { self.y } } @@ -109,7 +109,7 @@ macro_rules! impl_from { fn from((x, y): $from) -> Self { Self { x, - y: y as $as, + y: <$as>::try_from(y).expect("usize is within the height of terminal"), _phantom: PhantomData, } } @@ -119,7 +119,7 @@ macro_rules! impl_from { impl From<$from> for $for { fn from((x, y): $from) -> Self { Self { - x: x as $as, + x: <$as>::try_from(y).expect("usize is within the width of terminal"), y, _phantom: PhantomData, } @@ -193,6 +193,7 @@ impl Cursor for ScreenBuffer { new_pos.y = bounds.bottom(); } + u16::try_from(32_usize); self.cursor.set_pos_from(new_pos); } @@ -249,6 +250,11 @@ impl TranslatePos for ScreenBuffer { let buff_win = self.buff_rect(); let visible_y = pos.y.clamp(buff_win.top(), buff_win.bottom()); + + // Casting is fine because the viewport (buff_win) is the size of + // a user's terminal and visible_y - buff_win.top() simply returns + // a number somewhere within the height of the terminal + #[allow(clippy::cast_possible_truncation)] let term_y = (visible_y - buff_win.top()) as u16; let term_x = pos.x.clamp(buff_win.left(), buff_win.right()); @@ -257,8 +263,10 @@ impl TranslatePos for ScreenBuffer { } fn to_buff(&self, pos: Position) -> Position { - let buff_y = (self.view_start as u32 + pos.y as u32).into(); - let buff_x = pos.x.clamp(0, self.rect.width); + let buff_y = (u32::try_from(self.view_start) + .expect("ScreenBuffer is less than usize::MAX") + + u32::from(pos.y)); + let buff_x = pos.x.clamp(0, self.width()); Position::::from((buff_x, buff_y)) } @@ -267,9 +275,12 @@ impl TranslatePos for ScreenBuffer { impl ScreenBuffer { pub(crate) fn buff_rect(&self) -> Rect { Rect::from(( - (0_u16, self.view_start as u32), - self.rect.width, - self.rect.height as u32, + ( + 0_u16, + u32::try_from(self.view_start).expect("ScreenBuffer is less than usize::MAX"), + ), + self.width(), + u32::from(self.rect.height), )) } } diff --git a/sericom-core/src/screen/process/colors.rs b/sericom-core/src/screen/process/colors.rs index a5af30b..71162af 100644 --- a/sericom-core/src/screen/process/colors.rs +++ b/sericom-core/src/screen/process/colors.rs @@ -5,9 +5,8 @@ use crossterm::{ use miette::IntoDiagnostic; use std::io::Write; -use crate::configs::get_config; - use super::SEP; +use crate::configs::get_config; #[derive(Debug)] pub struct ColorState { @@ -26,7 +25,8 @@ impl Default for ColorState { } impl ColorState { - pub fn get_colors(&self) -> Colors { + #[must_use] + pub const fn get_colors(&self) -> Colors { self.colors } pub fn queue_line(&self, writer: &mut W, text: &str) -> miette::Result<()> { @@ -43,19 +43,16 @@ impl ColorState { self.colors = Colors::new(Color::Reset, Color::Reset); } pub fn set_colors(&mut self, ascii_str: &str) { - self.colors = match Colored::parse_ansi(ascii_str) { - Some(colored) => { - eprintln!("{:#?}", colored); - self.colors.then(&colored.into()) - } - None => { - match Color::parse_ansi(ascii_str) { - Some(color) => eprintln!("Second try got: {:#?}", color), - None => eprintln!("Failed to parse ascii_str second time"), - } - eprintln!("Failed to parse ascii_str"); - self.colors + self.colors = if let Some(colored) = Colored::parse_ansi(ascii_str) { + eprintln!("{colored:#?}"); + self.colors.then(&colored.into()) + } else { + match Color::parse_ansi(ascii_str) { + Some(color) => eprintln!("Second try got: {color:#?}"), + None => eprintln!("Failed to parse ascii_str second time"), } + eprintln!("Failed to parse ascii_str"); + self.colors }; } } @@ -192,345 +189,3 @@ fn handle_colors_and_attrs(body: &[u8], color_state: &mut ColorState, attrs: &mu _ => {} // Ignore rest } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::configs::{ConfigOverride, get_config, initialize_config}; - use crossterm::style::{Attribute, Attributes, Color}; - - const CONF_OR: ConfigOverride = ConfigOverride { - color: None, - out_dir: None, - exit_script: None, - }; - - struct Case<'a> { - seq: &'a [u8], - expected_fg: Option, - expected_bg: Option, - expected_attrs: Vec, - label: &'a str, - } - - #[allow(clippy::trivially_copy_pass_by_ref)] - fn has_all(attrs: &Attributes, expected: &[Attribute]) -> bool { - expected.iter().all(|a| attrs.has(*a)) - } - - fn test_cases(cases: &Vec) { - for (idx, case) in cases.iter().enumerate() { - let mut color_state = ColorState::default(); - let mut attrs = Attributes::default(); - - process_colors(case.seq, &mut color_state, &mut attrs); - - assert_eq!( - color_state.get_colors().foreground, - case.expected_fg, - "Case# {} - Foreground mismatch on '{}'", - idx, - case.label - ); - assert_eq!( - color_state.get_colors().background, - case.expected_bg, - "Case# {} - Background mismatch on '{}'", - idx, - case.label - ); - assert!( - has_all(&attrs, &case.expected_attrs), - "Case# {} - Missing attrs on '{}': expected {:?}, got {:?}", - idx, - case.label, - case.expected_attrs, - attrs - ); - } - } - - #[test] - fn test_basic_fg() { - initialize_config(CONF_OR).ok(); - let config = get_config(); - let bg = Color::from(&config.appearance.bg); - - let cases = vec![ - Case { - seq: b"\x1b[31m", - expected_fg: Some(Color::DarkRed), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "FG basic red", - }, - Case { - seq: b"\x1b[37m", - expected_fg: Some(Color::Grey), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "FG basic white/grey", - }, - ]; - test_cases(&cases); - } - - #[test] - fn test_basic_bg() { - initialize_config(CONF_OR).ok(); - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - - let cases = vec![Case { - seq: b"\x1b[44m", - expected_fg: Some(fg), - expected_bg: Some(Color::DarkBlue), - expected_attrs: vec![], - label: "BG basic blue", - }]; - test_cases(&cases); - } - - #[test] - fn test_bright_colors() { - initialize_config(CONF_OR).ok(); - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); - - let cases = vec![ - Case { - seq: b"\x1b[95m", - expected_fg: Some(Color::Magenta), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "FG bright magenta", - }, - Case { - seq: b"\x1b[106m", - expected_fg: Some(fg), - expected_bg: Some(Color::Cyan), - expected_attrs: vec![], - label: "BG bright cyan", - }, - ]; - test_cases(&cases); - } - - #[test] - fn test_resets_and_defaults() { - initialize_config(CONF_OR).ok(); - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); - - let cases = vec![ - Case { - seq: b"\x1b[0m", - expected_fg: Some(fg), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "Reset", - }, - Case { - seq: b"\x1b[39m", - expected_fg: Some(Color::Reset), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "Reset FG default", - }, - Case { - seq: b"\x1b[49m", - expected_fg: Some(fg), - expected_bg: Some(Color::Reset), - expected_attrs: vec![], - label: "Reset BG default", - }, - ]; - test_cases(&cases); - } - - #[test] - fn test_attributes() { - initialize_config(CONF_OR).ok(); - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); - - let cases = vec![Case { - seq: b"\x1b[1;3;4m", - expected_fg: Some(fg), - expected_bg: Some(bg), - expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], - label: "Bold + Italic + Underlined", - }]; - test_cases(&cases); - } - - #[test] - fn test_256_color_palette() { - initialize_config(CONF_OR).ok(); - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); - - let cases = vec![ - Case { - seq: b"\x1b[38;5;196m", - expected_fg: Some(Color::AnsiValue(196)), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "FG 256 red", - }, - Case { - seq: b"\x1b[48;5;27m", - expected_fg: Some(fg), - expected_bg: Some(Color::AnsiValue(27)), - expected_attrs: vec![], - label: "BG 256 blue", - }, - ]; - test_cases(&cases); - } - - #[test] - fn test_truecolor_palette() { - initialize_config(CONF_OR).ok(); - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); - - let cases = vec![ - Case { - seq: b"\x1b[38;2;255;128;64m", - expected_fg: Some(Color::Rgb { - r: 255, - g: 128, - b: 64, - }), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "FG truecolor orange", - }, - Case { - seq: b"\x1b[48;2;10;20;30m", - expected_fg: Some(fg), - expected_bg: Some(Color::Rgb { - r: 10, - g: 20, - b: 30, - }), - expected_attrs: vec![], - label: "BG truecolor dark", - }, - ]; - test_cases(&cases); - } - - #[test] - fn test_mix_attr_colors() { - initialize_config(CONF_OR).ok(); - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); - - let cases = vec![ - Case { - seq: b"\x1b[1;3;4;38;5;202m", - expected_fg: Some(Color::AnsiValue(202)), - expected_bg: Some(bg), - expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], - label: "Bold + Italic + Underlined + FG 256 orange", - }, - Case { - seq: b"\x1b[5;7;48;2;128;64;200m", - expected_fg: Some(fg), - expected_bg: Some(Color::Rgb { - r: 128, - g: 64, - b: 200, - }), - expected_attrs: vec![Attribute::SlowBlink, Attribute::Reverse], - label: "Blink + Reverse + BG truecolor purple", - }, - ]; - test_cases(&cases); - } - - #[test] - fn test_kitchen_sink() { - initialize_config(CONF_OR).ok(); - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); - - let cases = vec![ - Case { - seq: b"\x1b[1;3;38;5;202;4;48;2;10;20;30m", - expected_fg: Some(Color::AnsiValue(202)), - expected_bg: Some(Color::Rgb { - r: 10, - g: 20, - b: 30, - }), - expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], - label: "Bold + Italic + Underlined + FG 256 + BG truecolor", - }, - Case { - seq: b"\x1b[20;53m", - expected_fg: Some(fg), - expected_bg: Some(bg), - expected_attrs: vec![Attribute::Fraktur, Attribute::OverLined], - label: "Fraktur + Overlined", - }, - ]; - test_cases(&cases); - } - - #[test] - fn test_invalid() { - initialize_config(CONF_OR).ok(); - let config = get_config(); - let fg = Color::from(&config.appearance.fg); - let bg = Color::from(&config.appearance.bg); - - let cases = vec![ - Case { - seq: b"\x1b[38;5m", - expected_fg: Some(fg), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "Incomplete 256 FG (missing index)", - }, - Case { - seq: b"\x1b[48;5;999m", - expected_fg: Some(fg), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "Out-of-range 256 BG (999)", - }, - Case { - seq: b"\x1b[38;2;255;0m", - expected_fg: Some(fg), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "Incomplete truecolor FG (missing B)", - }, - Case { - seq: b"\x1b[48;2;256;256;256m", - expected_fg: Some(fg), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "Invalid RGB components (>255)", - }, - Case { - seq: b"\x1b[999m", - expected_fg: Some(fg), - expected_bg: Some(bg), - expected_attrs: vec![], - label: "Unknown SGR param", - }, - ]; - test_cases(&cases); - } -} diff --git a/sericom-core/src/screen/process/cursor.rs b/sericom-core/src/screen/process/cursor.rs index 27d24de..2b8df75 100644 --- a/sericom-core/src/screen/process/cursor.rs +++ b/sericom-core/src/screen/process/cursor.rs @@ -1,7 +1,7 @@ use crate::{ screen::ScreenBuffer, + screen::position::{Cursor, Position}, screen::process::SEP, - ui::{Cursor, Position}, }; fn ascii_digits_to_integer(body: &[u8]) -> Option { @@ -14,12 +14,17 @@ fn ascii_digits_to_integer(body: &[u8]) -> Option { ) } -pub fn process_cursor(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { +pub fn process_cursor( + seq: &[u8], + kind: u8, + sb: &mut ScreenBuffer, + stdout: &mut W, +) { let body = &seq[2..seq.len() - 1]; // ESC[{row};{col}H // move cursor to line # col # - if (kind == b'H' && !body.is_empty()) || kind == b'f' { + if (kind == b'H' || kind == b'f') && !body.is_empty() { let mut parts = body.split(|&b| b == SEP); let row = parts.next().and_then(ascii_digits_to_integer); let col = parts.next().and_then(ascii_digits_to_integer); @@ -28,6 +33,7 @@ pub fn process_cursor(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { } } else if kind == b'n' && body == [b'6'] { // request cursor pos + // need to send to device via ESC[row;colR todo!(); } else { let nums = ascii_digits_to_integer(body); @@ -76,8 +82,8 @@ pub fn process_cursor(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { sb.set_cursor_col(n); } } - b's' => sb.save_cursor_pos(), - b'u' => sb.restore_cursor_pos(), + b's' => sb.save_cursor_pos(stdout), // can use crossterm + b'u' => sb.restore_cursor_pos(stdout), // can use crossterm b'H' => sb.set_cursor_pos(Position::ORIGIN), _ => {} } diff --git a/sericom-core/src/screen/process/mod.rs b/sericom-core/src/screen/process/mod.rs index 7daf3d1..14d7d6c 100644 --- a/sericom-core/src/screen/process/mod.rs +++ b/sericom-core/src/screen/process/mod.rs @@ -1,14 +1,15 @@ mod colors; mod cursor; mod parser; +mod screen; pub use colors::{ColorState, process_colors}; pub use cursor::process_cursor; pub use parser::{ByteParser, ParseState, ParserEvent}; +pub use screen::{process_erase, process_screen}; #[cfg(test)] -pub(crate) mod test; - +pub(crate) mod tests; pub(crate) use parser::*; /// Bracket '[' diff --git a/sericom-core/src/screen/process/screen.rs b/sericom-core/src/screen/process/screen.rs new file mode 100644 index 0000000..0c4a1a8 --- /dev/null +++ b/sericom-core/src/screen/process/screen.rs @@ -0,0 +1,62 @@ +use crate::screen::ScreenBuffer; + +pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { + let body = &seq[2..seq.len() - 1]; + + // erase from cursor until end of screen + if kind == b'J' && (body.is_empty() || body == [b'0']) { + todo!(); + } + + // erase from cursor until end of line + if kind == b'K' && (body.is_empty() || body == [b'0']) { + todo!(); + } + + match (kind, body) { + // erase from cursor to beginning of screen + (b'J', [b'1']) => todo!(), + // erase entire screen + (b'J', [b'2']) => todo!(), + // erase saved lines + (b'J', [b'3']) => todo!(), + // erase start of line to the cursor + (b'K', [b'1']) => todo!(), + // erase the entire line + (b'K', [b'2']) => todo!(), + _ => {} + } + + todo!() +} + +pub fn process_screen(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { + let body = &seq[2..seq.len() - 1]; + // ESC[={value}h Changes the screen width or type to the mode specified by value + + // erase from cursor until end of screen + if kind == b'J' && (body.is_empty() || body == [b'0']) { + todo!(); + } + + // erase from cursor until end of line + if kind == b'K' && (body.is_empty() || body == [b'0']) { + todo!(); + } + + match (kind, body) { + // erase from cursor to beginning of screen + (b'J', [b'1']) => todo!(), + // erase entire screen + (b'J', [b'2']) => todo!(), + // erase saved lines + (b'J', [b'3']) => todo!(), + // erase start of line to the cursor + (b'K', [b'1']) => todo!(), + // erase the entire line + (b'K', [b'2']) => todo!(), + _ => {} + } + + todo!() +} diff --git a/sericom-core/src/screen/process/tests/colors.rs b/sericom-core/src/screen/process/tests/colors.rs new file mode 100644 index 0000000..a23f260 --- /dev/null +++ b/sericom-core/src/screen/process/tests/colors.rs @@ -0,0 +1,338 @@ +use crate::configs::{ConfigOverride, get_config, initialize_config}; +use crate::screen::process::*; +use crossterm::style::{Attribute, Attributes, Color}; + +const CONF_OR: ConfigOverride = ConfigOverride { + color: None, + out_dir: None, + exit_script: None, +}; + +struct Case<'a> { + seq: &'a [u8], + expected_fg: Option, + expected_bg: Option, + expected_attrs: Vec, + label: &'a str, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn has_all(attrs: &Attributes, expected: &[Attribute]) -> bool { + expected.iter().all(|a| attrs.has(*a)) +} + +fn test_cases(cases: &Vec) { + for (idx, case) in cases.iter().enumerate() { + let mut color_state = ColorState::default(); + let mut attrs = Attributes::default(); + + process_colors(case.seq, &mut color_state, &mut attrs); + + assert_eq!( + color_state.get_colors().foreground, + case.expected_fg, + "Case# {} - Foreground mismatch on '{}'", + idx, + case.label + ); + assert_eq!( + color_state.get_colors().background, + case.expected_bg, + "Case# {} - Background mismatch on '{}'", + idx, + case.label + ); + assert!( + has_all(&attrs, &case.expected_attrs), + "Case# {} - Missing attrs on '{}': expected {:?}, got {:?}", + idx, + case.label, + case.expected_attrs, + attrs + ); + } +} + +#[test] +fn test_basic_fg() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[31m", + expected_fg: Some(Color::DarkRed), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG basic red", + }, + Case { + seq: b"\x1b[37m", + expected_fg: Some(Color::Grey), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG basic white/grey", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_basic_bg() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + + let cases = vec![Case { + seq: b"\x1b[44m", + expected_fg: Some(fg), + expected_bg: Some(Color::DarkBlue), + expected_attrs: vec![], + label: "BG basic blue", + }]; + test_cases(&cases); +} + +#[test] +fn test_bright_colors() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[95m", + expected_fg: Some(Color::Magenta), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG bright magenta", + }, + Case { + seq: b"\x1b[106m", + expected_fg: Some(fg), + expected_bg: Some(Color::Cyan), + expected_attrs: vec![], + label: "BG bright cyan", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_resets_and_defaults() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[0m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Reset", + }, + Case { + seq: b"\x1b[39m", + expected_fg: Some(Color::Reset), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Reset FG default", + }, + Case { + seq: b"\x1b[49m", + expected_fg: Some(fg), + expected_bg: Some(Color::Reset), + expected_attrs: vec![], + label: "Reset BG default", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_attributes() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![Case { + seq: b"\x1b[1;3;4m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], + label: "Bold + Italic + Underlined", + }]; + test_cases(&cases); +} + +#[test] +fn test_256_color_palette() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[38;5;196m", + expected_fg: Some(Color::AnsiValue(196)), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG 256 red", + }, + Case { + seq: b"\x1b[48;5;27m", + expected_fg: Some(fg), + expected_bg: Some(Color::AnsiValue(27)), + expected_attrs: vec![], + label: "BG 256 blue", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_truecolor_palette() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[38;2;255;128;64m", + expected_fg: Some(Color::Rgb { + r: 255, + g: 128, + b: 64, + }), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "FG truecolor orange", + }, + Case { + seq: b"\x1b[48;2;10;20;30m", + expected_fg: Some(fg), + expected_bg: Some(Color::Rgb { + r: 10, + g: 20, + b: 30, + }), + expected_attrs: vec![], + label: "BG truecolor dark", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_mix_attr_colors() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[1;3;4;38;5;202m", + expected_fg: Some(Color::AnsiValue(202)), + expected_bg: Some(bg), + expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], + label: "Bold + Italic + Underlined + FG 256 orange", + }, + Case { + seq: b"\x1b[5;7;48;2;128;64;200m", + expected_fg: Some(fg), + expected_bg: Some(Color::Rgb { + r: 128, + g: 64, + b: 200, + }), + expected_attrs: vec![Attribute::SlowBlink, Attribute::Reverse], + label: "Blink + Reverse + BG truecolor purple", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_kitchen_sink() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[1;3;38;5;202;4;48;2;10;20;30m", + expected_fg: Some(Color::AnsiValue(202)), + expected_bg: Some(Color::Rgb { + r: 10, + g: 20, + b: 30, + }), + expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], + label: "Bold + Italic + Underlined + FG 256 + BG truecolor", + }, + Case { + seq: b"\x1b[20;53m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![Attribute::Fraktur, Attribute::OverLined], + label: "Fraktur + Overlined", + }, + ]; + test_cases(&cases); +} + +#[test] +fn test_invalid() { + initialize_config(CONF_OR).ok(); + let config = get_config(); + let fg = Color::from(&config.appearance.fg); + let bg = Color::from(&config.appearance.bg); + + let cases = vec![ + Case { + seq: b"\x1b[38;5m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Incomplete 256 FG (missing index)", + }, + Case { + seq: b"\x1b[48;5;999m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Out-of-range 256 BG (999)", + }, + Case { + seq: b"\x1b[38;2;255;0m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Incomplete truecolor FG (missing B)", + }, + Case { + seq: b"\x1b[48;2;256;256;256m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Invalid RGB components (>255)", + }, + Case { + seq: b"\x1b[999m", + expected_fg: Some(fg), + expected_bg: Some(bg), + expected_attrs: vec![], + label: "Unknown SGR param", + }, + ]; + test_cases(&cases); +} diff --git a/sericom-core/src/screen/process/test.rs b/sericom-core/src/screen/process/tests/escape.rs similarity index 64% rename from sericom-core/src/screen/process/test.rs rename to sericom-core/src/screen/process/tests/escape.rs index 434b502..0d9f364 100644 --- a/sericom-core/src/screen/process/test.rs +++ b/sericom-core/src/screen/process/tests/escape.rs @@ -5,8 +5,7 @@ use crossterm::style::{Attribute, Attributes, Color}; use super::*; use crate::{ configs::{ConfigOverride, initialize_config}, - screen::*, - ui::{Line, Position, Rect}, + screen::{driver::ScreenDriver, *}, }; const CONFIG_OVERRIDE: ConfigOverride = ConfigOverride { color: None, @@ -16,18 +15,20 @@ const CONFIG_OVERRIDE: ConfigOverride = ConfigOverride { const TERMINAL_SIZE: (u16, u16) = (80, 24); macro_rules! setup { - ($sb:ident, $parser:ident) => { + ($sb:ident, $parser:ident, $stdout:ident) => { initialize_config(CONFIG_OVERRIDE).ok(); let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); let mut $sb = ScreenBuffer::new(rect); let mut $parser = ByteParser::new(); + let mut $stdout = std::io::stdout(); }; - ($sb:ident, $parser:ident, $config:ident) => { + ($sb:ident, $parser:ident, $config:ident, $stdout:ident) => { initialize_config(CONFIG_OVERRIDE).ok(); let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); let mut $sb = ScreenBuffer::new(rect); let mut $parser = ByteParser::new(); let $config = $crate::configs::get_config(); + let mut $stdout = std::io::stdout(); }; } @@ -54,7 +55,7 @@ macro_rules! assert_line_eq { "Line {line_idx} mismatch!\n Expected: {:?}\n Actual: {:?}\n\nFull buffer:\n{}", expected, actual, - $crate::ui::test::debug_dump(&sb.lines), + $crate::screen::process::tests::escape::debug_dump(&sb.lines), ); } }}; @@ -96,7 +97,7 @@ macro_rules! assert_span_eq { "Span {span_idx} text mismatch!\n Expected: {:?}\n Actual: {:?}\n\nFull buffer:\n{}", expected_text, actual_text, - $crate::ui::test::debug_dump(&sb.lines), + $crate::screen::process::tests::escape::debug_dump(&sb.lines), ); } )? @@ -110,7 +111,7 @@ macro_rules! assert_span_eq { span_idx, $fg_color, span.colors.foreground, - $crate::ui::test::debug_dump(&sb.lines), + $crate::screen::process::tests::escape::debug_dump(&sb.lines), ); )? $( @@ -121,7 +122,7 @@ macro_rules! assert_span_eq { span_idx, $bg_color, span.colors.background, - $crate::ui::test::debug_dump(&sb.lines), + $crate::screen::process::tests::escape::debug_dump(&sb.lines), ); )? $( @@ -132,7 +133,7 @@ macro_rules! assert_span_eq { span_idx, $attrs, span.attrs, - $crate::ui::test::debug_dump(&sb.lines), + $crate::screen::process::tests::escape::debug_dump(&sb.lines), ); )? }}; @@ -163,97 +164,117 @@ fn debug_dump(lines: &VecDeque) -> String { } #[test] -fn test_single_plain_line() { - setup!(sb, parser, config); +fn single_plain_line() { + setup!(sb, parser, config, stdout); let parsed = parser.feed(b"Hello, world!\n"); - sb.process_events(parsed); + let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + // Changed pos.x == 0 because handling \n like \r\n for now + // assert_eq!(sb.cursor, Position::::from((13_u16, 1_u16))); + assert_eq!(sb.cursor, Position::::from((0_u16, 1_u16))); // Expect one line with one span, fg=default, text padded - assert_eq!(sb.cursor, Position { x: 13, y: 1 }); - assert_line_eq!(sb, 1, "Hello, world!"); - assert_span_eq!(sb, 1, 0, fg => fg, bg => bg); + assert_line_eq!(sb, 0, "Hello, world!"); + assert_span_eq!(sb, 0, 0, fg => fg, bg => bg); } #[test] -fn test_two_lines_plain_text() { - setup!(sb, parser, config); +fn two_lines_plain_text() { + setup!(sb, parser, config, stdout); let parsed = parser.feed(b"Hello\r\nWorld\r\n"); - sb.process_events(parsed); + let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); // Expected: two lines, one with "Hello" padded, one with "World" padded - assert_eq!(sb.lines.len(), 3); // initial empty line + 2 - assert_eq!(sb.cursor, Position { x: 0, y: 2 }); - assert_line_eq!(sb, 1, "Hello"); - assert_line_eq!(sb, 2, "World"); + assert_eq!(sb.lines.len(), 3); + assert_eq!(sb.cursor, Position::::from((0_u16, 2_u16))); + assert_line_eq!(sb, 0, "Hello"); + assert_line_eq!(sb, 1, "World"); + assert_span_eq!(sb, 0, 0, fg => fg, bg => bg); assert_span_eq!(sb, 1, 0, fg => fg, bg => bg); - assert_span_eq!(sb, 2, 0, fg => fg, bg => bg); } #[test] -fn test_three_color_spans() { - setup!(sb, parser, config); +fn three_color_spans() { + setup!(sb, parser, config, stdout); let parsed = parser.feed(b"\x1b[31mRed\x1b[32mGreen\x1b[34mBlue\n"); - sb.process_events(parsed); + let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + driver.process_events(parsed); let bg = Color::from(&config.appearance.bg); - assert_eq!(sb.lines.len(), 2); // initial empty + 1 line - assert_eq!(sb.cursor, Position { x: 12, y: 1 }); - let line = sb.lines.get(1).unwrap(); + let line = sb.lines.front().unwrap(); + eprintln!( + "line num_cells: {}, num_spans: {}", + line.num_cells(), + line.len() + ); + assert_eq!(sb.lines.len(), 2); + // Changed pos.x == 0 because handling \n like \r\n for now + // assert_eq!(sb.cursor, Position::::from((12_u16, 1_u16))); + assert_eq!(sb.cursor, Position::::from((0_u16, 1_u16))); assert_eq!(line.len(), 3); // three spans - assert_span_eq!(sb, 1, 0, expected => "Red", fg => Color::DarkRed, bg => bg); - assert_span_eq!(sb, 1, 1, expected => "Green", fg => Color::DarkGreen, bg => bg); - assert_span_eq!(sb, 1, 2, expected => "Blue", fg => Color::DarkBlue, bg => bg); + assert_span_eq!(sb, 0, 0, expected => "Red", fg => Color::DarkRed, bg => bg); + assert_span_eq!(sb, 0, 1, expected => "Green", fg => Color::DarkGreen, bg => bg); + assert_span_eq!(sb, 0, 2, expected => "Blue", fg => Color::DarkBlue, bg => bg); } #[test] -fn test_no_newline_incomplete_line() { - setup!(sb, parser, config); +fn no_newline_incomplete_line() { + setup!(sb, parser, config, stdout); let parsed = parser.feed(b"Hello"); - sb.process_events(parsed); + let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); // Should still only contain the initial empty line assert_eq!(sb.lines.len(), 1); - assert_eq!(sb.cursor, Position { x: 5, y: 0 }); - assert_span_eq!(sb, 0, 0, expected => "", fg => fg, bg => bg); + assert_eq!(sb.cursor, Position::::from((5_u16, 0_u16))); + assert_span_eq!(sb, 0, 0, expected => "Hello", fg => fg, bg => bg); } #[test] -fn test_mixed_plain_and_color() { - setup!(sb, parser, config); +fn mixed_plain_and_color() { + setup!(sb, parser, config, stdout); let parsed = parser.feed(b"Normal \x1b[31mRed\n"); - sb.process_events(parsed); + let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); // Expect two spans: "Normal " default, "Red" DarkRed - let line = sb.lines.get(1).unwrap(); + let line = sb.lines.front().unwrap(); + // 2 lines because of the '\n' + assert_eq!(sb.lines.len(), 2); + // 2 spans assert_eq!(line.len(), 2); - assert_eq!(sb.cursor, Position { x: 10, y: 1 }); - assert_span_eq!(sb, 1, 0, expected => "Normal ", fg => fg, bg => bg); - assert_span_eq!(sb, 1, 1, expected => "Red", fg => Color::DarkRed, bg => bg); + // Changed pos.x == 0 because handling \n like \r\n for now + // assert_eq!(sb.cursor, Position::::from((10_u16, 1_u16))); + assert_eq!(sb.cursor, Position::::from((0_u16, 1_u16))); + assert_span_eq!(sb, 0, 0, expected => "Normal ", fg => fg, bg => bg); + assert_span_eq!(sb, 0, 1, expected => "Red", fg => Color::DarkRed, bg => bg); } #[test] -fn test_bold_italic_span() { - setup!(sb, parser); +fn bold_italic_span() { + setup!(sb, parser, stdout); let parsed = parser.feed(b"\x1b[1;3mHello\n"); // bold + italic - sb.process_events(parsed); + let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + driver.process_events(parsed); let span_attrs = Attributes::from(Attribute::Bold) | Attributes::from(Attribute::Italic); - assert_span_eq!(sb, 1, 0, attrs => span_attrs); + assert_span_eq!(sb, 0, 0, attrs => span_attrs); } #[test] #[allow(clippy::cognitive_complexity)] -fn test_multiline_multicolor() { - setup!(sb, parser, config); +fn multiline_multicolor() { + setup!(sb, parser, config, stdout); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -276,26 +297,27 @@ fn test_multiline_multicolor() { .as_bytes(); let parsed = parser.feed(input); - sb.process_events(parsed); + let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + driver.process_events(parsed); // Buffer should have initial empty + 3 lines assert_eq!(sb.lines.len(), 4); // Line 1 - assert_line_eq!(sb, 1, "Red Cyan BoldUnderBlue"); - assert_span_eq!(sb, 1, 0, expected => "Red", fg => Color::DarkRed, bg => bg); - assert_span_eq!(sb, 1, 1, expected => "Cyan", fg => Color::Cyan, bg => bg); + assert_line_eq!(sb, 0, "Red Cyan BoldUnderBlue"); + assert_span_eq!(sb, 0, 0, expected => "Red", fg => Color::DarkRed, bg => bg); + assert_span_eq!(sb, 0, 1, expected => "Cyan", fg => Color::Cyan, bg => bg); let span_attrs = Attributes::from(Attribute::Bold) | Attributes::from(Attribute::Underlined); - assert_span_eq!(sb, 1, 2, expected => "BoldUnderBlue", fg => Color::DarkBlue, bg => bg, attrs => span_attrs); + assert_span_eq!(sb, 0, 2, expected => "BoldUnderBlue", fg => Color::DarkBlue, bg => bg, attrs => span_attrs); // Line 2 - assert_line_eq!(sb, 2, "OrangeOnBlue ResetHere"); - assert_span_eq!(sb, 2, 0, expected => "OrangeOnBlue", fg => Color::AnsiValue(202), bg => Color::AnsiValue(27)); - assert_span_eq!(sb, 2, 1, expected => "ResetHere", fg => fg, bg => bg, attrs => Attributes::default()); + assert_line_eq!(sb, 1, "OrangeOnBlue ResetHere"); + assert_span_eq!(sb, 1, 0, expected => "OrangeOnBlue", fg => Color::AnsiValue(202), bg => Color::AnsiValue(27)); + assert_span_eq!(sb, 1, 1, expected => "ResetHere", fg => fg, bg => bg, attrs => Attributes::default()); // Line 3 - assert_line_eq!(sb, 3, "TrueColorGreenish BgPinkItalic"); - assert_span_eq!(sb, 3, 0, expected => "TrueColorGreenish", fg => Color::Rgb { r:128, g:200, b:64 }, bg => bg); + assert_line_eq!(sb, 2, "TrueColorGreenish BgPinkItalic"); + assert_span_eq!(sb, 2, 0, expected => "TrueColorGreenish", fg => Color::Rgb { r:128, g:200, b:64 }, bg => bg); let italic = Attributes::from(Attribute::Italic); - assert_span_eq!(sb, 3, 1, expected => "BgPinkItalic", fg => Color::Rgb { r:128, g:200, b:64 }, bg => Color::Rgb { r:200, g:64, b:128 }, attrs => italic); + assert_span_eq!(sb, 2, 1, expected => "BgPinkItalic", fg => Color::Rgb { r:128, g:200, b:64 }, bg => Color::Rgb { r:200, g:64, b:128 }, attrs => italic); } diff --git a/sericom-core/src/screen/process/tests/mod.rs b/sericom-core/src/screen/process/tests/mod.rs new file mode 100644 index 0000000..2cfe4a6 --- /dev/null +++ b/sericom-core/src/screen/process/tests/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod colors; +pub(crate) mod escape; diff --git a/sericom-core/src/screen/rect.rs b/sericom-core/src/screen/rect.rs index 84f520a..4cf91e1 100644 --- a/sericom-core/src/screen/rect.rs +++ b/sericom-core/src/screen/rect.rs @@ -1,7 +1,6 @@ -#![allow(unused)] use std::cmp::{max, min}; -use super::{BuffPos, PosType, PosY, Position, Scope, TermPos}; +use super::position::{BuffPos, PosType, PosY, Position, Scope, TermPos}; // #[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] diff --git a/sericom-core/src/screen/render.rs b/sericom-core/src/screen/render.rs index c55ea3b..61f92e0 100644 --- a/sericom-core/src/screen/render.rs +++ b/sericom-core/src/screen/render.rs @@ -3,11 +3,9 @@ use crossterm::style::{Attributes, Color, Colors}; use std::io::BufWriter; use tracing::instrument; +use super::{ByteParser, Cell, Line, ParserEvent, Span, process::NL}; use super::{Cursor, ScreenBuffer, UIAction}; -use crate::{ - configs::get_config, - ui::{ByteParser, Cell, Line, NL, ParserEvent, Span}, -}; +use crate::configs::get_config; const MIN_RENDER_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_millis(33); diff --git a/sericom-core/src/screen/ui_command.rs b/sericom-core/src/screen/ui_command.rs index 59810a2..4c4e16e 100644 --- a/sericom-core/src/screen/ui_command.rs +++ b/sericom-core/src/screen/ui_command.rs @@ -1,7 +1,5 @@ -use crate::ui::{Position, TermPos}; - +use super::position::{Cursor, Position, TermPos}; use super::{Line, ScreenBuffer}; -use crate::ui::Cursor; /// `UICommand` is used for communication between stdin and the [`ScreenBuffer`]. #[non_exhaustive] @@ -77,7 +75,7 @@ impl UIAction for ScreenBuffer { fn start_selection(&mut self, pos: Position) { let absolute_line = self.view_start + usize::from(pos.y); self.clear_selection(); - self.selection_start = Some((pos.x, absolute_line)); + // self.selection_start = Some((pos.x, absolute_line)); // self.needs_render = true; } @@ -85,7 +83,7 @@ impl UIAction for ScreenBuffer { /// Where `screen_x` is the x-position and `screen_y` is the y-position (line). fn update_selection(&mut self, pos: Position) { let absolute_line = self.view_start + usize::from(pos.y); - self.selection_end = Some((pos.x, absolute_line)); + // self.selection_end = Some((pos.x, absolute_line)); self.update_selection_highlighting(); // self.needs_render = true; } @@ -95,8 +93,8 @@ impl UIAction for ScreenBuffer { for line in &mut self.lines { line.clear_selection(); } - self.selection_start = None; - self.selection_end = None; + // self.selection_start = None; + // self.selection_end = None; // self.needs_render = true; } @@ -122,16 +120,14 @@ impl UIAction for ScreenBuffer { self.lines.clear(); self.view_start = 0; self.set_cursor_pos((0_u16, 0_usize)); - self.lines - .push_back(Line::new_default(self.rect.width.into())); + self.lines.push_back(Line::new_default(self.width().into())); // self.needs_render = true; } /// Clears the current *visible* screen while keeping the buffer's history fn clear_screen(&mut self) { for _ in 0..self.rect.height { - self.lines - .push_back(Line::new_default(self.rect.width.into())); + self.lines.push_back(Line::new_default(self.width().into())); } self.view_start = self.lines.len().saturating_sub(self.rect.height as usize); // self.needs_render = true; @@ -140,77 +136,78 @@ impl UIAction for ScreenBuffer { impl ScreenBuffer { fn update_selection_highlighting(&mut self) { - for line in &mut self.lines { - line.clear_selection(); - } - - if let (Some((start_x, start_line)), Some((end_x, end_line))) = - (self.selection_start, self.selection_end) - { - let (start_line, start_x, end_line, end_x) = - // If start < end or if start = end, start x has to be less than end x - if start_line < end_line || (start_line == end_line && start_x <= end_x) { - (start_line, start_x, end_line, end_x) - } else { - (end_line, end_x, start_line, start_x) - }; - - for line_idx in start_line..=end_line { - if let Some(line) = self.lines.get_mut(line_idx) { - let line_start_x = if line_idx == start_line { start_x } else { 0 }; - let line_end_x = if line_idx == end_line { - end_x - } else { - self.rect.width - 1 - }; - - // for x in line_start_x..=line_end_x.min(self.width - 1) { - // if let Some(cell) = line.get_mut_cell(x as usize) { - // cell.is_selected = true; - // } - // } - } - } - } + // for line in &mut self.lines { + // line.clear_selection(); + // } + // + // if let (Some((start_x, start_line)), Some((end_x, end_line))) = + // // (self.selection_start, self.selection_end) + // { + // let (start_line, start_x, end_line, end_x) = + // // If start < end or if start = end, start x has to be less than end x + // if start_line < end_line || (start_line == end_line && start_x <= end_x) { + // (start_line, start_x, end_line, end_x) + // } else { + // (end_line, end_x, start_line, start_x) + // }; + // + // for line_idx in start_line..=end_line { + // if let Some(line) = self.lines.get_mut(line_idx) { + // let line_start_x = if line_idx == start_line { start_x } else { 0 }; + // let line_end_x = if line_idx == end_line { + // end_x + // } else { + // self.rect.width - 1 + // }; + // + // // for x in line_start_x..=line_end_x.min(self.width - 1) { + // // if let Some(cell) = line.get_mut_cell(x as usize) { + // // cell.is_selected = true; + // // } + // // } + // } + // } + // } } fn get_selected_text(&self) -> String { - if let (Some((start_x, start_line)), Some((end_x, end_line))) = - (self.selection_start, self.selection_end) - { - let (start_line, start_x, end_line, end_x) = - if start_line < end_line || (start_line == end_line && start_x <= end_x) { - (start_line, start_x, end_line, end_x) - } else { - (end_line, end_x, start_line, start_x) - }; - - let mut result = String::new(); - - for line_idx in start_line..=end_line { - if let Some(line) = self.lines.get(line_idx) { - let line_start_x = if line_idx == start_line { start_x } else { 0 }; - let line_end_x = if line_idx == end_line { - end_x - } else { - self.rect.width - 1 - }; - - // for x in line_start_x..=line_end_x.min(self.width - 1) { - // if let Some(cell) = line.get_cell(x as usize) { - // result.push(cell.character); - // } - // } - - if line_idx < end_line { - result.push('\n'); - } - } - } - - result.trim_end().to_string() - } else { - String::new() - } + todo!() + // if let (Some((start_x, start_line)), Some((end_x, end_line))) = + // (self.selection_start, self.selection_end) + // { + // let (start_line, start_x, end_line, end_x) = + // if start_line < end_line || (start_line == end_line && start_x <= end_x) { + // (start_line, start_x, end_line, end_x) + // } else { + // (end_line, end_x, start_line, start_x) + // }; + // + // let mut result = String::new(); + // + // for line_idx in start_line..=end_line { + // if let Some(line) = self.lines.get(line_idx) { + // let line_start_x = if line_idx == start_line { start_x } else { 0 }; + // let line_end_x = if line_idx == end_line { + // end_x + // } else { + // self.rect.width - 1 + // }; + // + // // for x in line_start_x..=line_end_x.min(self.width - 1) { + // // if let Some(cell) = line.get_cell(x as usize) { + // // result.push(cell.character); + // // } + // // } + // + // if line_idx < end_line { + // result.push('\n'); + // } + // } + // } + // + // result.trim_end().to_string() + // } else { + // String::new() + // } } } diff --git a/test-sericom/src/main.rs b/test-sericom/src/main.rs index 3e036cb..acc3610 100644 --- a/test-sericom/src/main.rs +++ b/test-sericom/src/main.rs @@ -9,48 +9,30 @@ mod pts; use pts::get_pts_pair; fn main() -> std::io::Result<()> { - let seq = b"\x1B[12G"; - let n = parse_number_field_bytes(seq); - println!("{n:?}"); // Some(12) + let (mut master, slave) = get_pts_pair()?; + println!("Got slave: {slave}"); - let seq = b"\x1B[46G"; - let n = parse_number_field_bytes(seq); - println!("{n:?}"); // Some(12) + let mut seri_guard = ChildGuard { + child: Some( + Command::new("/home/thomas/.cargo/bin/sericom") + .arg(slave) + .spawn() + .expect("Failed to start 'sericom'"), + ), + }; - for b in b"0123456789" { - println!("{b} '{}' → {}", *b as char, b - b'0'); - } - for b in b"0123456789" { - println!("{}: {:08b} digit = {}", *b as char, b, b & 0x0F); - } + master.write_all(b"Hello from simulated serial!\r\n")?; + master.flush()?; + + thread::sleep(Duration::from_secs(2)); - let sub = b'5' - b'0'; - let and = b'5' & 0x0F; - println!("SUB: {:08b}, AND: {:08b}", sub, and); - // let (mut master, slave) = get_pts_pair()?; - // println!("Got slave: {slave}"); - // - // let mut seri_guard = ChildGuard { - // child: Some( - // Command::new("/home/thomas/.cargo/bin/sericom") - // .arg(slave) - // .spawn() - // .expect("Failed to start 'sericom'"), - // ), - // }; - // - // master.write_all(b"Hello from simulated serial!\r\n")?; - // master.flush()?; - // - // thread::sleep(Duration::from_secs(2)); - // - // master.write_all(b"\x1B[2J")?; - // master.write_all(b"\x1B[H")?; - // master.write_all(b"Ready>\r\n")?; - // master.flush()?; - // - // thread::sleep(Duration::from_secs(5)); - // seri_guard.wait(); + master.write_all(b"\x1B[2J")?; + master.write_all(b"\x1B[H")?; + master.write_all(b"Ready>\r\n")?; + master.flush()?; + + thread::sleep(Duration::from_secs(5)); + seri_guard.wait(); Ok(()) } @@ -85,22 +67,3 @@ fn test_sub() { // assert!(body.is_empty()); assert!(body.len() == 1); } - -fn parse_number_field_bytes(seq: &[u8]) -> Option { - let body = &seq[2..seq.len() - 1]; - if body.is_empty() || !body.iter().all(|b| b.is_ascii_digit()) { - return None; - } - - Some( - body.iter() - .fold(0u32, |acc, b| acc * 10 + (b & 0x0F) as u32) - ) -} - -#[test] -fn test_parse() { - let seq = b"\x1B[12G"; - let n = parse_number_field_bytes(seq); - println!("{n:?}"); // Some(12) -} diff --git a/test-sericom/src/pts.rs b/test-sericom/src/pts.rs index c57c265..9295ec5 100644 --- a/test-sericom/src/pts.rs +++ b/test-sericom/src/pts.rs @@ -1,8 +1,4 @@ -use std::{ - ffi::CStr, - fs::File, - os::fd::FromRawFd, -}; +use std::{ffi::CStr, fs::File, os::fd::FromRawFd}; #[allow(non_camel_case_types)] type c_int = i32; From a1f2968695f22b8f61d4dfa7a89bbbb2700e8b1a Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Mon, 24 Nov 2025 23:54:18 -0600 Subject: [PATCH 09/40] commit before I delete stuff and/or put things behind feature flags I was writing code (specifically for handling the clear [screen|line] escape sequences when I realized that `crossterm` has some nice things I could use here instead of implementing it myself and facing (potentially) more bugs. I was about to delete nearly everything I had written for that function when I realized the code could probably be used when not writing to a terminal backend. I've been planning to make a GUI version with a different feature set and would not have a terminal for crossterm to render to. So I'm making this commit before I start screwing around with feature flags. changelog: ignore --- sericom-core/src/screen/buffer.rs | 121 +++++---------------- sericom-core/src/screen/components/cell.rs | 2 + sericom-core/src/screen/components/line.rs | 33 +++++- sericom-core/src/screen/components/span.rs | 36 ++++-- sericom-core/src/screen/driver.rs | 47 +++++--- sericom-core/src/screen/position.rs | 13 --- sericom-core/src/screen/process/screen.rs | 118 ++++++++++++-------- 7 files changed, 193 insertions(+), 177 deletions(-) diff --git a/sericom-core/src/screen/buffer.rs b/sericom-core/src/screen/buffer.rs index 860ed1a..4238107 100644 --- a/sericom-core/src/screen/buffer.rs +++ b/sericom-core/src/screen/buffer.rs @@ -1,6 +1,8 @@ use crossterm::style::Attributes; use std::collections::VecDeque; +use crate::screen::BuffPos; + use super::position::{Position, TermPos, TranslatePos}; use super::{Line, Rect}; @@ -55,6 +57,10 @@ impl ScreenBuffer { self.rect.width } + pub(crate) const fn height(&self) -> u16 { + self.rect.height + } + pub(crate) fn push_line(&mut self, line: Line) { self.lines.push_back(line); } @@ -79,7 +85,10 @@ impl ScreenBuffer { line.split_spans(colors, attrs, curr_col, remainder); } - pub(crate) fn with_current_span(&mut self, f: F) { + pub(crate) fn with_current_span)>( + &mut self, + f: F, + ) { let buff_pos = self.to_buff(self.cursor); let span = { @@ -88,27 +97,22 @@ impl ScreenBuffer { line.get_mut_span(span_idx).expect("verified") }; - f(span); + f(span, &buff_pos); } - pub(crate) fn with_current_line(&mut self, f: F) { + pub(crate) fn with_current_line)>( + &mut self, + f: F, + ) { let buff_pos = self.to_buff(self.cursor); - let line = self.curr_line_mut(); - f(line); + f(line, &buff_pos); } - pub(crate) fn curr_line(&mut self) -> &Line { + pub(crate) fn curr_line(&self) -> Option<&Line> { let pos_in_lines = self.to_buff(self.cursor); - if self.lines.get(pos_in_lines.y as usize).is_some() { - self.lines - .get(pos_in_lines.y as usize) - .expect("verified that line exists") - } else { - self.push_line(Line::reserve_new(self.width() as usize)); - self.lines.back().expect("is not empty") - } + self.lines.get(pos_in_lines.y as usize) } pub(crate) fn curr_line_mut(&mut self) -> &mut Line { @@ -122,82 +126,15 @@ impl ScreenBuffer { self.lines.back_mut().expect("is not empty") } } - // fn clear_from_cursor_to_sol(&mut self) { - // if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { - // // line.reset_to(self.cursor.x as usize); - // } - // } - - // fn clear_from_cursor_to_sos(&mut self) { - // self.clear_from_cursor_to_sol(); - // for line in self - // .lines - // .range_mut(self.view_start..usize::from(self.cursor.y)) - // { - // line.reset(); - // } - // } - - // fn clear_from_cursor_to_eol(&mut self) { - // if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { - // // line.reset_from(self.cursor.x as usize); - // } - // } - - // fn clear_from_cursor_to_eos(&mut self) { - // self.clear_from_cursor_to_eol(); - // for line in self.lines.range_mut(usize::from(self.cursor.y) + 1..) { - // line.reset(); - // } - // } - - // fn clear_whole_line(&mut self) { - // if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) { - // line.reset(); - // } - // } - - // fn new_line(&mut self) { - // // TODO: F*X THIS - // // self.set_cursor_pos((0, self.cursor.y + 1)); - // - // if usize::from(self.cursor.y) >= self.lines.len() { - // self.lines - // .push_back(Line::new_default(usize::from(self.width()))); - // } - // - // // Remove old lines if exceeding `ScreenBuffer.max_scrollback` - // while self.lines.len() > self.max_scrollback { - // self.lines.pop_front(); - // // Update the view position - // if self.cursor.y > 0 { - // self.cursor.y -= 1; - // } - // if self.view_start > 0 { - // self.view_start -= 1; - // } - // } - // } - - // fn set_char_at_cursor(&mut self, ch: char) { - // while usize::from(self.cursor.y) >= self.lines.len() { - // self.lines - // .push_back(Line::new_default(usize::from(self.width()))); - // } - // - // if let Some(line) = self.lines.get_mut(usize::from(self.cursor.y)) - // && (self.cursor.x as usize) < line.len() - // { - // // line.set_char(self.cursor.x as usize, ch); - // } - // } - - // pub(crate) fn line_from_cursor(&mut self) -> usize { - // let line_idx = self.view_start + usize::from(self.cursor.y); - // while line_idx > self.lines.len() { - // self.lines - // .push_back(Line::new_empty(usize::from(self.rect.width))); - // } - // line_idx - // } + + pub(crate) fn buff_rect(&self) -> Rect { + Rect::from(( + ( + 0_u16, + u32::try_from(self.view_start).expect("ScreenBuffer is less than usize::MAX"), + ), + self.width(), + u32::from(self.height()), + )) + } } diff --git a/sericom-core/src/screen/components/cell.rs b/sericom-core/src/screen/components/cell.rs index ded64d3..55791e1 100644 --- a/sericom-core/src/screen/components/cell.rs +++ b/sericom-core/src/screen/components/cell.rs @@ -13,7 +13,9 @@ pub struct Cell { } impl Cell { + pub const CARRIGE: Self = Self::new('\r'); pub const EMPTY: Self = Self::new(' '); + pub const NEWLINE: Self = Self::new('\n'); pub const TAB: Self = Self::new('\t'); #[must_use] diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index a0c2692..9274830 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -30,7 +30,7 @@ impl Line { /// Create a new line with a single [`Span`] with the length/size of `width`. /// - /// Filled with [`Span::EMPTY`]. + /// The [`Span`] is filled with [`Cell::EMPTY`]. #[must_use] pub fn new_empty(width: usize) -> Self { Self(vec![Span::new_empty(width); 1]) @@ -55,7 +55,8 @@ impl Line { // /// Iterates over the [`Cell`]s to index `idx` within [`Self`] // /// and sets them to [`Cell::default()`]. // pub fn reset_to(&mut self, idx: usize) { - // self.0[..idx] + // let (span, offset) = self.span_at_col(idx); + // self.0[..=span] // .iter_mut() // .for_each(|cell| *cell = Cell::default()); // } @@ -319,3 +320,31 @@ impl IndexMut for Line { // assert_eq!(line1.get_span(0).unwrap().cells.get(10).unwrap(), &cell); // } // } + +#[test] +fn flatten_spans_to_cells() { + use crate::configs::*; + + const CONF_OR: ConfigOverride = ConfigOverride { + color: None, + out_dir: None, + exit_script: None, + }; + + initialize_config(CONF_OR).ok(); + + let span = Span::new_empty(5); + let mut line = Line::new(4, span); + + assert_eq!(line.len(), 4); + + let idx: usize = 12; + + let mut acc = 0; + line.iter_mut().flatten().skip(idx).for_each(|mut cell| { + cell.character = 'c'; + dbg!(cell); + }); + + assert_eq!(3, 4); +} diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index d71f9b5..9b367a2 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -23,15 +23,6 @@ impl Default for Span { } impl Span { - pub(crate) fn tab() -> Self { - let colors = Self::get_config_colors(); - Self { - cells: vec![Cell::TAB; 1], - attrs: Attributes::default(), - colors, - } - } - fn get_config_colors() -> Colors { let config = get_config(); let fg = Color::from(&config.appearance.fg); @@ -112,6 +103,33 @@ impl Span { } } +impl IntoIterator for Span { + type Item = Cell; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.cells.into_iter() + } +} + +impl<'a> IntoIterator for &'a Span { + type Item = &'a Cell; + type IntoIter = std::slice::Iter<'a, Cell>; + + fn into_iter(self) -> Self::IntoIter { + self.cells.iter() + } +} + +impl<'a> IntoIterator for &'a mut Span { + type Item = &'a mut Cell; + type IntoIter = std::slice::IterMut<'a, Cell>; + + fn into_iter(self) -> Self::IntoIter { + self.cells.iter_mut() + } +} + impl Index for Span { type Output = Cell; fn index(&self, index: usize) -> &Self::Output { diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/driver.rs index bc186a6..da00812 100644 --- a/sericom-core/src/screen/driver.rs +++ b/sericom-core/src/screen/driver.rs @@ -12,6 +12,7 @@ pub struct ScreenDriver<'a, W: std::io::Write> { buffer: &'a mut ScreenBuffer, color_state: ColorState, attrs: Attributes, + // stdout gives access to call crossterm::execute!/queue! stdout: &'a mut W, } @@ -38,10 +39,11 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { // TODO: HANDLE CHECKING TO SEE IF THE CURSOR IS OVER AN EXISTING // SPAN/LINE BEFORE WRITING - IF SO, NEED TO SPLIT APPROPRIATLY fn write_text(&mut self, bytes: &[u8]) { + self.buffer.curr_line().is_some_and(|line| line.len() > 0); let chars: Vec = bytes.iter().map(|b| char::from(*b)).collect(); let num_chars = chars.len(); - self.buffer.with_current_span(|span| { + self.buffer.with_current_span(|span, _| { for ch in chars { span.push(Cell::new(ch)); } @@ -57,26 +59,35 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { match ctrl { BS => self.buffer.move_cursor_left(1), NL => { - let remainder = - self.buffer.width() as usize - (self.buffer.curr_line().num_cells()); - if remainder != 0 { - self.buffer.with_current_span(|span| { - let width = remainder + span.cells.len(); - span.fill_to_width(width); - }); - } + self.buffer.with_current_span(|span, _| { + span.push(Cell::NEWLINE); + span.shrink(); + }); + self.buffer.move_cursor_down(1); self.buffer .push_line(Line::reserve_new(self.buffer.width() as usize)); - self.buffer.set_cursor_col(0); - self.buffer.move_cursor_down(1); + // let remainder = self.buffer.width() as usize + // - (self.buffer.curr_line().map_or_else(|| 0, Line::num_cells)); + // if remainder != 0 { + // self.buffer.with_current_span(|span, _| { + // let width = remainder + span.cells.len(); + // span.fill_to_width(width); + // }); + // } + // self.buffer + // .push_line(Line::reserve_new(self.buffer.width() as usize)); + // self.buffer.set_cursor_col(0); } - TAB => self.buffer.with_current_span(|span| { + TAB => self.buffer.with_current_span(|span, _| { span.push(Cell::TAB); }), - // Need to make the events peekable to handle '\r\n' for now - // lets just roll with ignoring it and see what happens - #[allow(clippy::match_same_arms)] - CR => {} // self.buffer.set_cursor_col(0), + CR => { + self.buffer.with_current_span(|span, _| { + span.push(Cell::CARRIGE); + span.shrink(); + }); + self.buffer.set_cursor_col(0); + } // Not sure that FF needs to be handled #[allow(clippy::match_same_arms)] FF => {} @@ -86,13 +97,13 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { fn handle_escape(&mut self, seq: &[u8]) { let Some(seq_type) = classify_escape_seq(seq) else { - todo!(); + return; }; match seq_type { EscSequenceType::Cursor(kind) => { process_cursor(seq, kind, self.buffer, self.stdout); } - EscSequenceType::Erase(kind) => process_erase(seq, kind, self.buffer), + EscSequenceType::Erase(kind) => process_erase(seq, kind, self.buffer, self.stdout), EscSequenceType::Graphics => { process_colors(seq, &mut self.color_state, &mut self.attrs); self.buffer diff --git a/sericom-core/src/screen/position.rs b/sericom-core/src/screen/position.rs index 338f325..9accb9d 100644 --- a/sericom-core/src/screen/position.rs +++ b/sericom-core/src/screen/position.rs @@ -271,16 +271,3 @@ impl TranslatePos for ScreenBuffer { Position::::from((buff_x, buff_y)) } } - -impl ScreenBuffer { - pub(crate) fn buff_rect(&self) -> Rect { - Rect::from(( - ( - 0_u16, - u32::try_from(self.view_start).expect("ScreenBuffer is less than usize::MAX"), - ), - self.width(), - u32::from(self.rect.height), - )) - } -} diff --git a/sericom-core/src/screen/process/screen.rs b/sericom-core/src/screen/process/screen.rs index 0c4a1a8..bde78f0 100644 --- a/sericom-core/src/screen/process/screen.rs +++ b/sericom-core/src/screen/process/screen.rs @@ -1,62 +1,94 @@ -use crate::screen::ScreenBuffer; +use std::ops::{Range, RangeInclusive}; -pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { - let body = &seq[2..seq.len() - 1]; - - // erase from cursor until end of screen - if kind == b'J' && (body.is_empty() || body == [b'0']) { - todo!(); - } +use crate::screen::{Line, Position, ScreenBuffer, TermPos, TranslatePos}; - // erase from cursor until end of line - if kind == b'K' && (body.is_empty() || body == [b'0']) { - todo!(); - } +pub fn process_erase( + seq: &[u8], + kind: u8, + sb: &mut ScreenBuffer, + stdout: &mut W, +) { + let body = &seq[2..seq.len() - 1]; match (kind, body) { + // erase from cursor until end of screen + (b'J', [] | [b'0']) => { + let buff_range = Range { + start: (sb.to_buff(sb.cursor).y + 1) as usize, + end: (sb.buff_rect().bottom() + 1) as usize, + }; + + sb.clear_line_from_cursor(); + sb.clear_lines(buff_range); + } // erase from cursor to beginning of screen - (b'J', [b'1']) => todo!(), - // erase entire screen - (b'J', [b'2']) => todo!(), - // erase saved lines - (b'J', [b'3']) => todo!(), + (b'J', [b'1']) => { + // clear from beginning of screen to current line + let buff_range = Range { + start: sb.view_start, + end: sb.to_buff(sb.cursor).y as usize, + }; + + sb.clear_lines(buff_range); + sb.clear_line_to_cursor(); + } + // erase entire screen - move cursor to ORIGIN + // erase saved lines - same as erase entire screen + // to preserve user scrollback history + (b'J', [b'2' | b'3']) => { + sb.lines.push_back(Line::new_empty(usize::from(sb.width()))); + sb.view_start = sb.lines.len().saturating_sub(1); + sb.cursor = Position::::ORIGIN; + } + // erase from cursor until end of line + (b'K', [] | [b'0']) => sb.clear_line_from_cursor(), // erase start of line to the cursor - (b'K', [b'1']) => todo!(), + (b'K', [b'1']) => sb.clear_line_to_cursor(), // erase the entire line - (b'K', [b'2']) => todo!(), + (b'K', [b'2']) => { + sb.with_current_line(|line, _| { + line.iter_mut().flatten().for_each(|mut cell| { + cell.character = ' '; + }); + }); + } _ => {} } - - todo!() } -pub fn process_screen(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { - let body = &seq[2..seq.len() - 1]; - // ESC[={value}h Changes the screen width or type to the mode specified by value - - // erase from cursor until end of screen - if kind == b'J' && (body.is_empty() || body == [b'0']) { - todo!(); +impl ScreenBuffer { + pub(crate) fn clear_line_to_cursor(&mut self) { + self.with_current_line(|line, cursor| { + for (idx, cell) in line.iter_mut().flatten().enumerate() { + if idx < usize::from(cursor.x) { + cell.character = ' '; + } + } + }); } - // erase from cursor until end of line - if kind == b'K' && (body.is_empty() || body == [b'0']) { - todo!(); + pub(crate) fn clear_line_from_cursor(&mut self) { + self.with_current_line(|line, cursor| { + line.iter_mut() + .flatten() + .skip(usize::from(cursor.x)) + .for_each(|mut cell| { + cell.character = ' '; + }); + }); } - match (kind, body) { - // erase from cursor to beginning of screen - (b'J', [b'1']) => todo!(), - // erase entire screen - (b'J', [b'2']) => todo!(), - // erase saved lines - (b'J', [b'3']) => todo!(), - // erase start of line to the cursor - (b'K', [b'1']) => todo!(), - // erase the entire line - (b'K', [b'2']) => todo!(), - _ => {} + pub(crate) fn clear_lines(&mut self, range: Range) { + for line in self.lines.range_mut(range) { + line.iter_mut().flatten().for_each(|cell| { + cell.character = ' '; + }); + } } +} +pub fn process_screen(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { + let body = &seq[2..seq.len() - 1]; + // ESC[={value}h Changes the screen width or type to the mode specified by value todo!() } From 01afca031fda872231d74b660b510b08f2191c0d Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Tue, 25 Nov 2025 23:15:12 -0600 Subject: [PATCH 10/40] implemented `crossterm::Command` for `Line` Figured out that I could just implement crossterm's `Command` trait to make rendering easier for the cli. changelog: ignore --- sericom-core/src/screen/components/cell.rs | 16 +++++++++++ sericom-core/src/screen/components/line.rs | 23 ++++++++++++++++ sericom-core/src/screen/components/mod.rs | 6 +++++ sericom-core/src/screen/components/span.rs | 31 ++++++++++++++++++++-- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/sericom-core/src/screen/components/cell.rs b/sericom-core/src/screen/components/cell.rs index 55791e1..2722ae4 100644 --- a/sericom-core/src/screen/components/cell.rs +++ b/sericom-core/src/screen/components/cell.rs @@ -1,3 +1,5 @@ +use std::ops::{Deref, DerefMut}; + use crossterm::style::Color; use crate::configs::get_config; @@ -27,6 +29,20 @@ impl Cell { } } +impl Deref for Cell { + type Target = char; + + fn deref(&self) -> &Self::Target { + &self.character + } +} + +impl DerefMut for Cell { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.character + } +} + impl From for Cell { fn from(value: char) -> Self { Self::new(value) diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index 9274830..2352e7a 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -1,8 +1,10 @@ +use std::fmt::Display; use std::ops::{Index, IndexMut}; use crossterm::style::Attributes; use crate::screen::ColorState; +use crossterm::csi; use super::Cell; use super::Span; @@ -11,6 +13,17 @@ use super::Span; #[derive(Debug, Clone, Eq, PartialEq, Default)] pub struct Line(pub Vec); +impl crossterm::Command for Line { + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + use crossterm::style::PrintStyledContent; + for span in self.iter() { + PrintStyledContent(span.styled()).write_ansi(f)?; + } + + Ok(()) + } +} + impl Line { /// Create a new line with the length/size of `width`. /// @@ -348,3 +361,13 @@ fn flatten_spans_to_cells() { assert_eq!(3, 4); } + +// impl std::fmt::Display for Line { +// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +// use crossterm::QueueableCommand; +// use crossterm::style::{PrintStyledContent, StyledContent}; +// let v: Vec> = self.iter().map(Span::styled).collect(); +// let mut out = std::io::stdout(); +// todo!(); +// } +// } diff --git a/sericom-core/src/screen/components/mod.rs b/sericom-core/src/screen/components/mod.rs index 1e5e00e..739487f 100644 --- a/sericom-core/src/screen/components/mod.rs +++ b/sericom-core/src/screen/components/mod.rs @@ -5,3 +5,9 @@ mod span; pub use cell::Cell; pub use line::Line; pub use span::Span; + +#[macro_export] +#[doc(hidden)] +macro_rules! csi { + ($( $l:expr ),*) => { concat!("\x1B[", $( $l ),*) }; +} diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index 9b367a2..2642499 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -1,6 +1,11 @@ -use std::ops::{Index, IndexMut}; +use std::{ + fmt::Formatter, + ops::{Deref, Index, IndexMut}, +}; -use crossterm::style::{Attribute, Attributes, Color, Colors}; +use crossterm::style::{ + Attribute, Attributes, Color, Colors, ContentStyle, StyledContent, Stylize, +}; use crate::{configs::get_config, screen::Cell, screen::process::ColorState}; @@ -142,3 +147,25 @@ impl IndexMut for Span { &mut self.cells[index] } } + +impl Span { + fn content_style(&self) -> ContentStyle { + ContentStyle { + foreground_color: self.colors.foreground, + background_color: self.colors.background, + underline_color: None, + attributes: self.attrs, + } + } + + pub(crate) fn styled(&self) -> StyledContent { + let s = String::from_iter(self.cells.iter()); + self.content_style().apply(s) + } +} + +impl<'a> FromIterator<&'a Cell> for std::string::String { + fn from_iter>(iter: T) -> Self { + iter.into_iter().map(Deref::deref).collect::() + } +} From 94b53bbeebac1a477606c925ecc934b7ca3407a3 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Tue, 25 Nov 2025 23:24:25 -0600 Subject: [PATCH 11/40] refactor(render): Impl `Command` and test cursor movement/screen clearing I remember now why I had the `ScreenBuffer::set_char_at_cursor()` method - for maintaining spacing and allowing easy overwriting. Will need to bring this method, along with it's functionality back, unless I think of a different way; but it seems that this would kill a couple birds with one stone i.e. formatting regardless of backed (file/stdout) and overwriting characters. --- sericom-core/Cargo.toml | 7 +- sericom-core/src/lib.rs | 3 + sericom-core/src/screen/buffer.rs | 1 + sericom-core/src/screen/components/cell.rs | 6 + sericom-core/src/screen/components/line.rs | 203 ++++++++---------- sericom-core/src/screen/components/span.rs | 66 ++++-- sericom-core/src/screen/driver.rs | 72 +++++-- sericom-core/src/screen/mod.rs | 3 + sericom-core/src/screen/position.rs | 16 +- sericom-core/src/screen/process/cursor.rs | 12 +- sericom-core/src/screen/process/mod.rs | 2 - sericom-core/src/screen/process/parser.rs | 52 +++++ sericom-core/src/screen/process/screen.rs | 30 ++- sericom-core/src/screen/process/tests/mod.rs | 2 - .../src/screen/{process => }/tests/colors.rs | 0 sericom-core/src/screen/tests/cursor.rs | 98 +++++++++ .../src/screen/{process => }/tests/escape.rs | 164 +------------- sericom-core/src/screen/tests/line.rs | 65 ++++++ sericom-core/src/screen/tests/mod.rs | 166 ++++++++++++++ sericom-core/src/screen/ui_command.rs | 6 +- sericom-core/src/ui/buffer.rs | 9 +- 21 files changed, 647 insertions(+), 336 deletions(-) delete mode 100644 sericom-core/src/screen/process/tests/mod.rs rename sericom-core/src/screen/{process => }/tests/colors.rs (100%) create mode 100644 sericom-core/src/screen/tests/cursor.rs rename sericom-core/src/screen/{process => }/tests/escape.rs (54%) create mode 100644 sericom-core/src/screen/tests/line.rs create mode 100644 sericom-core/src/screen/tests/mod.rs diff --git a/sericom-core/Cargo.toml b/sericom-core/Cargo.toml index 873ff7c..8188438 100644 --- a/sericom-core/Cargo.toml +++ b/sericom-core/Cargo.toml @@ -8,12 +8,17 @@ edition.workspace = true exclude.workspace = true repository.workspace = true +[features] +default = ["cli"] +cli = ["dep:crossterm"] +gui = [] + [dependencies] serde = { version = "1.0.228", features = ["derive"] } thiserror = "2.0.17" toml = "0.9.8" chrono.workspace = true -crossterm.workspace = true +crossterm = { workspace = true, optional = true } miette.workspace = true serial2-tokio.workspace = true tokio.workspace = true diff --git a/sericom-core/src/lib.rs b/sericom-core/src/lib.rs index 9556c28..d1c4b1b 100644 --- a/sericom-core/src/lib.rs +++ b/sericom-core/src/lib.rs @@ -11,6 +11,9 @@ #![allow(clippy::missing_errors_doc)] #![allow(clippy::missing_panics_doc)] +#[cfg(all(feature = "cli", feature = "gui"))] +compile_error!("features `cli` and `gui` cannot be enabled simultaneosly"); + pub mod cli; pub mod configs; pub mod debug; diff --git a/sericom-core/src/screen/buffer.rs b/sericom-core/src/screen/buffer.rs index 4238107..81ce4aa 100644 --- a/sericom-core/src/screen/buffer.rs +++ b/sericom-core/src/screen/buffer.rs @@ -105,6 +105,7 @@ impl ScreenBuffer { f: F, ) { let buff_pos = self.to_buff(self.cursor); + eprintln!("buffer pos: {buff_pos:?}"); let line = self.curr_line_mut(); f(line, &buff_pos); diff --git a/sericom-core/src/screen/components/cell.rs b/sericom-core/src/screen/components/cell.rs index 2722ae4..710ce68 100644 --- a/sericom-core/src/screen/components/cell.rs +++ b/sericom-core/src/screen/components/cell.rs @@ -49,6 +49,12 @@ impl From for Cell { } } +impl From<&char> for Cell { + fn from(value: &char) -> Self { + Self::new(*value) + } +} + impl From for char { fn from(value: Cell) -> Self { value.character diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index 2352e7a..6495c50 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -1,7 +1,7 @@ use std::fmt::Display; use std::ops::{Index, IndexMut}; -use crossterm::style::Attributes; +use crossterm::style::{Attributes, Colors, SetAttributes, SetColors}; use crate::screen::ColorState; use crossterm::csi; @@ -13,34 +13,7 @@ use super::Span; #[derive(Debug, Clone, Eq, PartialEq, Default)] pub struct Line(pub Vec); -impl crossterm::Command for Line { - fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { - use crossterm::style::PrintStyledContent; - for span in self.iter() { - PrintStyledContent(span.styled()).write_ansi(f)?; - } - - Ok(()) - } -} - impl Line { - /// Create a new line with the length/size of `width`. - /// - /// Filled with `span`. - #[must_use] - pub fn new(width: usize, span: Span) -> Self { - Self(vec![span; width]) - } - - /// Create a new line with the length/size of `width`. - /// - /// Filled with [`Cell::default()`]. - #[must_use] - pub fn new_default(width: usize) -> Self { - Self(vec![Span::default(); width]) - } - /// Create a new line with a single [`Span`] with the length/size of `width`. /// /// The [`Span`] is filled with [`Cell::EMPTY`]. @@ -58,36 +31,6 @@ impl Line { Self(vec![Span::reserve_new(width, None, None); 1]) } - /// Iterates over all the [`Cell`]s within the line and sets them to [`Cell::default()`]. - pub fn reset(&mut self) { - self.0.iter_mut().for_each(|span| { - span.reset(); - }); - } - - // /// Iterates over the [`Cell`]s to index `idx` within [`Self`] - // /// and sets them to [`Cell::default()`]. - // pub fn reset_to(&mut self, idx: usize) { - // let (span, offset) = self.span_at_col(idx); - // self.0[..=span] - // .iter_mut() - // .for_each(|cell| *cell = Cell::default()); - // } - - // /// Iterates over the [`Cell`]s from index `idx` within [`Self`] - // /// to the end of [`Self`] and sets them to [`Cell::default()`]. - // pub fn reset_from(&mut self, idx: usize) { - // self.0 - // .iter_mut() - // .skip(idx) - // .for_each(|cell| *cell = Cell::default()); - // } - // - // /// Sets the character in [`Cell`] at [`Self`]\[`idx`\] to `ch`. - // pub fn set_char(&mut self, idx: usize, ch: char) { - // self.0[idx].character = ch; - // } - /// Util function to return the length of [`Self`]. #[must_use] #[allow(clippy::len_without_is_empty)] @@ -99,7 +42,8 @@ impl Line { pub fn clear_selection(&mut self) { self.0 .iter_mut() - .for_each(|span| span.iter_mut().for_each(|cell| cell.is_selected = false)); + .flatten() + .for_each(|cell| cell.is_selected = false); } /// Returns a reference to [`Span`] at `idx`. @@ -169,21 +113,6 @@ impl Line { self.0.push(span); } - // /// Returns the index of the last cell within a line where the [`Cell`] != [`Cell::EMPTY`] - // pub fn last_cell_idx(&self) -> usize { - // if self.0.is_empty() { - // return 0; - // } - // let mut offset = self.0.iter().map(|s| s.cells.len()).sum::(); - // for span in self.0.iter().rev() { - // offset -= span.cells.len(); - // if let Some(pos) = span.cells.iter().rposition(|c| *c != Cell::EMPTY) { - // return offset + pos; - // } - // } - // 0 - // } - /// Shrinks the span at `col` to `span.len()` and creates a new span with /// capacity of `fill_to`, `colors` and `attrs`. See [`Span::reserve_new`]. pub fn split_spans( @@ -252,6 +181,94 @@ impl IndexMut for Line { } } +impl crossterm::Command for Line { + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + use crossterm::style::PrintStyledContent; + let mut spans = self.iter(); + let Some(first) = spans.next() else { + return Ok(()); + }; + + let mut colors = first.colors; + let mut attrs = first.attrs; + + SetColors(colors).write_ansi(f)?; + SetAttributes(attrs).write_ansi(f)?; + first.write_ansi(f)?; + + for span in spans { + if span.colors != colors { + SetColors(span.colors).write_ansi(f)?; + colors = span.colors; + } + + if span.attrs != attrs { + SetAttributes(span.attrs).write_ansi(f)?; + attrs = span.attrs; + } + + span.write_ansi(f)?; + } + + Ok(()) + } +} + +// impl Line { +// /// Create a new line with the length/size of `width`. +// /// +// /// Filled with `span`. +// #[must_use] +// pub fn new(width: usize, span: Span) -> Self { +// Self(vec![span; width]) +// } + +// /// Iterates over all the [`Cell`]s within the line and sets them to [`Cell::default()`]. +// pub fn reset(&mut self) { +// self.0.iter_mut().for_each(|span| { +// span.reset(); +// }); +// } + +// /// Iterates over the [`Cell`]s to index `idx` within [`Self`] +// /// and sets them to [`Cell::default()`]. +// pub fn reset_to(&mut self, idx: usize) { +// let (span, offset) = self.span_at_col(idx); +// self.0[..=span] +// .iter_mut() +// .for_each(|cell| *cell = Cell::default()); +// } + +// /// Iterates over the [`Cell`]s from index `idx` within [`Self`] +// /// to the end of [`Self`] and sets them to [`Cell::default()`]. +// pub fn reset_from(&mut self, idx: usize) { +// self.0 +// .iter_mut() +// .skip(idx) +// .for_each(|cell| *cell = Cell::default()); +// } +// +// /// Sets the character in [`Cell`] at [`Self`]\[`idx`\] to `ch`. +// pub fn set_char(&mut self, idx: usize, ch: char) { +// self.0[idx].character = ch; +// } + +// /// Returns the index of the last cell within a line where the [`Cell`] != [`Cell::EMPTY`] +// pub fn last_cell_idx(&self) -> usize { +// if self.0.is_empty() { +// return 0; +// } +// let mut offset = self.0.iter().map(|s| s.cells.len()).sum::(); +// for span in self.0.iter().rev() { +// offset -= span.cells.len(); +// if let Some(pos) = span.cells.iter().rposition(|c| *c != Cell::EMPTY) { +// return offset + pos; +// } +// } +// 0 +// } +// } + // #[cfg(test)] // mod tests { // use super::*; @@ -333,41 +350,3 @@ impl IndexMut for Line { // assert_eq!(line1.get_span(0).unwrap().cells.get(10).unwrap(), &cell); // } // } - -#[test] -fn flatten_spans_to_cells() { - use crate::configs::*; - - const CONF_OR: ConfigOverride = ConfigOverride { - color: None, - out_dir: None, - exit_script: None, - }; - - initialize_config(CONF_OR).ok(); - - let span = Span::new_empty(5); - let mut line = Line::new(4, span); - - assert_eq!(line.len(), 4); - - let idx: usize = 12; - - let mut acc = 0; - line.iter_mut().flatten().skip(idx).for_each(|mut cell| { - cell.character = 'c'; - dbg!(cell); - }); - - assert_eq!(3, 4); -} - -// impl std::fmt::Display for Line { -// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -// use crossterm::QueueableCommand; -// use crossterm::style::{PrintStyledContent, StyledContent}; -// let v: Vec> = self.iter().map(Span::styled).collect(); -// let mut out = std::io::stdout(); -// todo!(); -// } -// } diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index 2642499..92190d1 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -3,8 +3,9 @@ use std::{ ops::{Deref, Index, IndexMut}, }; -use crossterm::style::{ - Attribute, Attributes, Color, Colors, ContentStyle, StyledContent, Stylize, +use crossterm::{ + Command, + style::{Attribute, Attributes, Color, Colors, ContentStyle, StyledContent, Stylize}, }; use crate::{configs::get_config, screen::Cell, screen::process::ColorState}; @@ -34,12 +35,15 @@ impl Span { let bg = Color::from(&config.appearance.bg); Colors::new(fg, bg) } + pub(crate) const fn set_attrs(&mut self, attrs: Attributes) { self.attrs = attrs; } + pub(crate) fn add_attr(&mut self, attr: Attribute) { self.attrs.set(attr); } + pub(crate) fn reset(&mut self) { self.cells.iter_mut().for_each(|cell| { *cell = Cell::EMPTY; @@ -47,6 +51,7 @@ impl Span { self.attrs = Attributes::default(); self.colors = Self::get_config_colors(); } + pub(crate) fn new_empty(width: usize) -> Self { let colors = Self::get_config_colors(); Self { @@ -55,6 +60,7 @@ impl Span { colors, } } + /// Creates a new [`Span`] and reserves space for `width` of [`Cell`]s. /// /// This calls [`Vec::with_capacity()`] and does not create any [`Cell`]s. @@ -73,31 +79,40 @@ impl Span { colors, } } + pub(crate) fn fill_to_width(&mut self, width: usize) { self.cells.resize(width, Cell::EMPTY); } + pub(crate) fn shrink(&mut self) { let size = self.cells.len(); self.cells.shrink_to(size); } + pub(crate) fn push(&mut self, cell: Cell) { self.cells.push(cell); } + pub(crate) const fn len(&self) -> usize { self.cells.len() } + pub(crate) const fn set_colors(&mut self, colors: &ColorState) { self.colors = colors.get_colors(); } + pub(crate) const fn is_empty(&self) -> bool { self.cells.is_empty() } + pub fn iter(&self) -> std::slice::Iter<'_, Cell> { self.cells.iter() } + pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Cell> { self.cells.iter_mut() } + /// Returns the number of [`Cell`]s in a [`Span`] that are not [`Cell::EMPTY`] #[must_use] pub fn num_filled_cells(&self) -> usize { @@ -106,6 +121,34 @@ impl Span { .filter(|&cell| *cell != Cell::EMPTY) .count() } + + const fn content_style(&self) -> ContentStyle { + ContentStyle { + foreground_color: self.colors.foreground, + background_color: self.colors.background, + underline_color: None, + attributes: self.attrs, + } + } + + pub(crate) const fn colors(&self) -> Colors { + self.colors + } + + pub(crate) const fn attrs(&self) -> Attributes { + self.attrs + } + + pub(crate) fn styled(&self) -> StyledContent { + let s = String::from_iter(&self.cells); + self.content_style().apply(s) + } +} + +impl Command for Span { + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + f.write_str(&String::from_iter(&self.cells)) + } } impl IntoIterator for Span { @@ -148,20 +191,15 @@ impl IndexMut for Span { } } -impl Span { - fn content_style(&self) -> ContentStyle { - ContentStyle { - foreground_color: self.colors.foreground, - background_color: self.colors.background, - underline_color: None, - attributes: self.attrs, +impl FromIterator for Span { + fn from_iter>(iter: T) -> Self { + let colors = Self::get_config_colors(); + Self { + cells: iter.into_iter().map(Cell::from).collect(), + colors, + attrs: Attributes::default(), } } - - pub(crate) fn styled(&self) -> StyledContent { - let s = String::from_iter(self.cells.iter()); - self.content_style().apply(s) - } } impl<'a> FromIterator<&'a Cell> for std::string::String { diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/driver.rs index da00812..5cc1394 100644 --- a/sericom-core/src/screen/driver.rs +++ b/sericom-core/src/screen/driver.rs @@ -28,6 +28,7 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { pub fn process_events(&mut self, events: Vec) { for ev in events { + eprintln!("{ev}"); match ev { ParserEvent::Text(bytes) => self.write_text(&bytes), ParserEvent::Control(ctrl) => self.handle_control(ctrl), @@ -36,14 +37,15 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { } } - // TODO: HANDLE CHECKING TO SEE IF THE CURSOR IS OVER AN EXISTING - // SPAN/LINE BEFORE WRITING - IF SO, NEED TO SPLIT APPROPRIATLY fn write_text(&mut self, bytes: &[u8]) { - self.buffer.curr_line().is_some_and(|line| line.len() > 0); let chars: Vec = bytes.iter().map(|b| char::from(*b)).collect(); let num_chars = chars.len(); - self.buffer.with_current_span(|span, _| { + self.buffer.with_current_span(|span, cursor| { + if !span.is_empty() { + span.cells.truncate(cursor.x.into()); + } + for ch in chars { span.push(Cell::new(ch)); } @@ -59,28 +61,43 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { match ctrl { BS => self.buffer.move_cursor_left(1), NL => { - self.buffer.with_current_span(|span, _| { - span.push(Cell::NEWLINE); - span.shrink(); + // #[cfg(feature = "cli")] + // { + self.buffer.with_current_line(|line, _| { + // pushing to the end of line unconditionally because + // NL is always the end of a line, and if received a CR + // before NL, then `with_current_span` would behave incorrect + if let Some(span) = line.0.last_mut() { + span.push(Cell::NEWLINE); + span.shrink(); + } }); + self.buffer.set_cursor_col(0); self.buffer.move_cursor_down(1); self.buffer .push_line(Line::reserve_new(self.buffer.width() as usize)); - // let remainder = self.buffer.width() as usize - // - (self.buffer.curr_line().map_or_else(|| 0, Line::num_cells)); - // if remainder != 0 { - // self.buffer.with_current_span(|span, _| { - // let width = remainder + span.cells.len(); - // span.fill_to_width(width); - // }); // } - // self.buffer - // .push_line(Line::reserve_new(self.buffer.width() as usize)); - // self.buffer.set_cursor_col(0); + // #[cfg(feature = "gui")] // fills span to ScreenBuffer::width() + // { + // let remainder = self.buffer.width() as usize + // - (self.buffer.curr_line().map_or_else(|| 0, Line::num_cells)); + // if remainder != 0 { + // self.buffer.with_current_span(|span, _| { + // let width = remainder + span.cells.len(); + // span.fill_to_width(width); + // }); + // } + // self.buffer + // .push_line(Line::reserve_new(self.buffer.width() as usize)); + // self.buffer.set_cursor_col(0); + // } + } + TAB => { + self.buffer.with_current_span(|span, _| { + span.push(Cell::TAB); + }); + self.buffer.cursor.tab(); } - TAB => self.buffer.with_current_span(|span, _| { - span.push(Cell::TAB); - }), CR => { self.buffer.with_current_span(|span, _| { span.push(Cell::CARRIGE); @@ -109,7 +126,7 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { self.buffer .handle_span_colors(&self.color_state, self.attrs); } - EscSequenceType::Screen(_kind) => {} // process_screen(seq, kind, self.buffer), + EscSequenceType::Screen(_kind) => { /* process_screen(seq, kind, self.buffer) */ } } } } @@ -126,7 +143,18 @@ pub enum EscSequenceType { Screen(u8), } -fn classify_escape_seq(seq: &[u8]) -> Option { +impl std::fmt::Display for EscSequenceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Cursor(b) => todo!(), + Self::Erase(b) => todo!(), + Self::Graphics => todo!(), + Self::Screen(b) => todo!(), + } + } +} + +pub(crate) fn classify_escape_seq(seq: &[u8]) -> Option { // https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 // Ensures the sequence resembles: ESC[ if seq.len() < 3 || seq[0] != ESC || seq[1] != BK { diff --git a/sericom-core/src/screen/mod.rs b/sericom-core/src/screen/mod.rs index 19fd23d..a86cc8e 100644 --- a/sericom-core/src/screen/mod.rs +++ b/sericom-core/src/screen/mod.rs @@ -35,6 +35,9 @@ pub use process::{ByteParser, ColorState, ParseState, ParserEvent}; pub use rect::Rect; pub use ui_command::{UIAction, UICommand}; +#[cfg(test)] +pub(crate) mod tests; + pub(in crate::screen) use process::{ process_colors, process_cursor, process_erase, process_screen, }; diff --git a/sericom-core/src/screen/position.rs b/sericom-core/src/screen/position.rs index 9accb9d..1130491 100644 --- a/sericom-core/src/screen/position.rs +++ b/sericom-core/src/screen/position.rs @@ -1,5 +1,7 @@ use std::{fmt::Display, marker::PhantomData}; +use crate::screen::Line; + use super::Rect; use super::ScreenBuffer; @@ -50,6 +52,10 @@ impl Position { } impl Position { + pub const fn tab(&mut self) { + let tab_width = 8; + let tab = tab_width - (self.x % tab_width); + } pub const fn set_y(&mut self, y: PosY) { self.y = y; } @@ -193,7 +199,6 @@ impl Cursor for ScreenBuffer { new_pos.y = bounds.bottom(); } - u16::try_from(32_usize); self.cursor.set_pos_from(new_pos); } @@ -214,15 +219,14 @@ impl Cursor for ScreenBuffer { self.cursor.y = self.cursor.y.saturating_sub(lines); } - // TODO: Add in logic for pushing empty/new lines to ScreenBuffer::lines - // if the cursor is trying to go past ScreenBuffer::lines.len() fn move_cursor_down(&mut self, lines: u16) { let bounds = self.bounds(); let mut new_y = self.cursor.y.saturating_add(lines); - if new_y > bounds.bottom() { - new_y = bounds.bottom(); + + // TODO: FIGURE OUT LINE PUSHING + if new_y <= bounds.bottom() { + self.cursor.y = new_y; } - self.cursor.y = new_y; } fn set_cursor_col(&mut self, col: u16) { diff --git a/sericom-core/src/screen/process/cursor.rs b/sericom-core/src/screen/process/cursor.rs index 2b8df75..41d50e2 100644 --- a/sericom-core/src/screen/process/cursor.rs +++ b/sericom-core/src/screen/process/cursor.rs @@ -1,7 +1,7 @@ -use crate::{ - screen::ScreenBuffer, - screen::position::{Cursor, Position}, - screen::process::SEP, +use crate::screen::{ + ScreenBuffer, TermPos, + position::{Cursor, Position}, + process::SEP, }; fn ascii_digits_to_integer(body: &[u8]) -> Option { @@ -28,8 +28,8 @@ pub fn process_cursor( let mut parts = body.split(|&b| b == SEP); let row = parts.next().and_then(ascii_digits_to_integer); let col = parts.next().and_then(ascii_digits_to_integer); - if let (Some(r), Some(c)) = (row, col) { - sb.set_cursor_pos((r, c)); + if let (Some(c), Some(r)) = (col, row) { + sb.set_cursor_pos((c, r)); } } else if kind == b'n' && body == [b'6'] { // request cursor pos diff --git a/sericom-core/src/screen/process/mod.rs b/sericom-core/src/screen/process/mod.rs index 14d7d6c..2a54faf 100644 --- a/sericom-core/src/screen/process/mod.rs +++ b/sericom-core/src/screen/process/mod.rs @@ -8,8 +8,6 @@ pub use cursor::process_cursor; pub use parser::{ByteParser, ParseState, ParserEvent}; pub use screen::{process_erase, process_screen}; -#[cfg(test)] -pub(crate) mod tests; pub(crate) use parser::*; /// Bracket '[' diff --git a/sericom-core/src/screen/process/parser.rs b/sericom-core/src/screen/process/parser.rs index b3a86d2..ca05f92 100644 --- a/sericom-core/src/screen/process/parser.rs +++ b/sericom-core/src/screen/process/parser.rs @@ -7,6 +7,58 @@ pub enum ParserEvent { EscapeSequence(Vec), } +impl std::fmt::Display for ParserEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use crate::screen::driver::EscSequenceType; + use crate::screen::driver::classify_escape_seq; + match self { + Self::Text(items) => { + let s = str::from_utf8(items).expect("parsed text is utf-8"); + f.write_fmt(format_args!("Text( {s} )")) + } + Self::Control(b) => f.write_fmt(format_args!("Control( {b:#X} )")), + Self::EscapeSequence(items) => { + let Some(etype) = classify_escape_seq(items) else { + return f.write_str("INVALID ESC SEQ"); + }; + + let s = match etype { + EscSequenceType::Cursor(_) => { + let mut s = String::new(); + for b in &items[2..items.len()] { + s.push(char::from(*b)); + } + format!("Cursor( ESC[{s} )") + } + EscSequenceType::Erase(_) => { + let mut s = String::new(); + for b in &items[2..items.len()] { + s.push(char::from(*b)); + } + format!("Erase( ESC[{s} )") + } + EscSequenceType::Graphics => { + let mut s = String::new(); + for b in &items[2..items.len()] { + s.push(char::from(*b)); + } + format!("Graphics( ESC[{s} )") + } + EscSequenceType::Screen(_) => { + let mut s = String::new(); + for b in &items[2..items.len()] { + s.push(char::from(*b)); + } + format!("Screen( ESC[{s} )") + } + }; + + f.write_str(&s) + } + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParseState { Normal, diff --git a/sericom-core/src/screen/process/screen.rs b/sericom-core/src/screen/process/screen.rs index bde78f0..4d1815c 100644 --- a/sericom-core/src/screen/process/screen.rs +++ b/sericom-core/src/screen/process/screen.rs @@ -1,3 +1,8 @@ +// #[cfg(feature = "cli")] +// use crossterm::{ +// execute, +// terminal::{Clear, ClearType}, +// }; use std::ops::{Range, RangeInclusive}; use crate::screen::{Line, Position, ScreenBuffer, TermPos, TranslatePos}; @@ -13,6 +18,8 @@ pub fn process_erase( match (kind, body) { // erase from cursor until end of screen (b'J', [] | [b'0']) => { + // #[cfg(feature = "gui")] + // { let buff_range = Range { start: (sb.to_buff(sb.cursor).y + 1) as usize, end: (sb.buff_rect().bottom() + 1) as usize, @@ -20,10 +27,18 @@ pub fn process_erase( sb.clear_line_from_cursor(); sb.clear_lines(buff_range); + // } + // + // #[cfg(feature = "cli")] + // execute!(stdout, Clear(ClearType::FromCursorDown)); } // erase from cursor to beginning of screen (b'J', [b'1']) => { - // clear from beginning of screen to current line + // #[cfg(feature = "cli")] + // execute!(stdout, Clear(ClearType::FromCursorUp)); + // + // #[cfg(feature = "gui")] + // { let buff_range = Range { start: sb.view_start, end: sb.to_buff(sb.cursor).y as usize, @@ -31,6 +46,7 @@ pub fn process_erase( sb.clear_lines(buff_range); sb.clear_line_to_cursor(); + // } } // erase entire screen - move cursor to ORIGIN // erase saved lines - same as erase entire screen @@ -39,6 +55,9 @@ pub fn process_erase( sb.lines.push_back(Line::new_empty(usize::from(sb.width()))); sb.view_start = sb.lines.len().saturating_sub(1); sb.cursor = Position::::ORIGIN; + + // #[cfg(feature = "cli")] + // execute!(stdout, Clear(ClearType::All)); } // erase from cursor until end of line (b'K', [] | [b'0']) => sb.clear_line_from_cursor(), @@ -46,11 +65,17 @@ pub fn process_erase( (b'K', [b'1']) => sb.clear_line_to_cursor(), // erase the entire line (b'K', [b'2']) => { + // #[cfg(feature = "cli")] + // execute!(stdout, Clear(ClearType::CurrentLine)); + // + // #[cfg(feature = "gui")] + // { sb.with_current_line(|line, _| { line.iter_mut().flatten().for_each(|mut cell| { cell.character = ' '; }); }); + // } } _ => {} } @@ -72,12 +97,13 @@ impl ScreenBuffer { line.iter_mut() .flatten() .skip(usize::from(cursor.x)) - .for_each(|mut cell| { + .for_each(|cell| { cell.character = ' '; }); }); } + // #[cfg(feature = "gui")] pub(crate) fn clear_lines(&mut self, range: Range) { for line in self.lines.range_mut(range) { line.iter_mut().flatten().for_each(|cell| { diff --git a/sericom-core/src/screen/process/tests/mod.rs b/sericom-core/src/screen/process/tests/mod.rs deleted file mode 100644 index 2cfe4a6..0000000 --- a/sericom-core/src/screen/process/tests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub(crate) mod colors; -pub(crate) mod escape; diff --git a/sericom-core/src/screen/process/tests/colors.rs b/sericom-core/src/screen/tests/colors.rs similarity index 100% rename from sericom-core/src/screen/process/tests/colors.rs rename to sericom-core/src/screen/tests/colors.rs diff --git a/sericom-core/src/screen/tests/cursor.rs b/sericom-core/src/screen/tests/cursor.rs new file mode 100644 index 0000000..671d1ac --- /dev/null +++ b/sericom-core/src/screen/tests/cursor.rs @@ -0,0 +1,98 @@ +use super::*; +use crate::{assert_line_eq, setup}; + +/// Bracket '[' +const BK: u8 = b'['; +/// Backspace +const BS: u8 = 0x08; +/// Carrige return '\r' +const CR: u8 = 0x0D; +/// Escape 'ESC' +const ESC: u8 = 0x1B; +/// Newline '\n' +const NL: u8 = 0x0A; +/// Escape sequence separator ';' +const SEP: u8 = b';'; +/// Tab '\t' +const TAB: u8 = 0x09; +/// Form feed +const FF: u8 = 0x0C; +/// Reset graphics mode escape sequence +const RESET: &[u8] = &[ESC, BK, b'0', b'm']; + +#[test] +fn clear_line_from_cursor() { + setup!(sb, parser, config, stdout); + let s = "This is the first line\nThis is the second line\nThis is the third line\n"; + let s = [ + s.as_bytes(), + &[ESC, BK, b'1', SEP, b'1', b'0', b'H'], // move cursor to (10, 1) + &[ESC, BK, b'K'], // clear line from cursor + ] + .concat(); + + let parsed = parser.feed(&s); + let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + driver.process_events(parsed); + + assert_eq!(sb.lines.len(), 4); + assert_eq!(sb.cursor, Position::::from((10u16, 1u16))); + assert_line_eq!(sb, 1, "This is th"); +} + +#[test] +fn clear_from_cursor_to_top() { + setup!(sb, parser, config, stdout); + let s = "This is the first line\n".to_owned() + + "This is the second line\n" + + "This is the third line\n" + + "This is the fourth line\n" + + "This is the fifth line\n"; + let s = [ + s.as_bytes(), + &[ESC, BK, b'3', SEP, b'1', b'0', b'H'], // move cursor to (10, 3) + &[ESC, BK, b'1', b'J'], // clear from cursor to beginning + ] + .concat(); + + let parsed = parser.feed(&s); + let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + driver.process_events(parsed); + + assert_eq!(sb.lines.len(), 6); + assert_eq!(sb.cursor, Position::::from((10u16, 3u16))); + assert_line_eq!(sb, 0, ""); + assert_line_eq!(sb, 1, ""); + assert_line_eq!(sb, 2, ""); + assert_line_eq!(sb, 3, " e fourth line"); + assert_line_eq!(sb, 4, "This is the fifth line"); +} + +#[test] +fn clear_and_overwrite() { + setup!(sb, parser, config, stdout); + let s = "This is the first line\n".to_owned() + + "This is the second line\n" + + "This is the third line\n" + + "This is the fourth line\n" + + "This is the fifth line\n"; + let s = [ + s.as_bytes(), + &[ESC, BK, b'3', SEP, b'1', b'0', b'H'], // move cursor to (10, 3) + &[ESC, BK, b'1', b'J'], // clear from cursor to beginning + b"\rNew words".as_slice(), + ] + .concat(); + + let parsed = parser.feed(&s); + let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + driver.process_events(parsed); + + assert_eq!(sb.lines.len(), 6); + // assert_eq!(sb.cursor, Position::::from((10u16, 3u16))); + assert_line_eq!(sb, 0, ""); + assert_line_eq!(sb, 1, ""); + assert_line_eq!(sb, 2, ""); + assert_line_eq!(sb, 3, "New words e fourth line"); + assert_line_eq!(sb, 4, "This is the fifth line"); +} diff --git a/sericom-core/src/screen/process/tests/escape.rs b/sericom-core/src/screen/tests/escape.rs similarity index 54% rename from sericom-core/src/screen/process/tests/escape.rs rename to sericom-core/src/screen/tests/escape.rs index 0d9f364..4899ad3 100644 --- a/sericom-core/src/screen/process/tests/escape.rs +++ b/sericom-core/src/screen/tests/escape.rs @@ -1,167 +1,5 @@ -use std::collections::VecDeque; - -use crossterm::style::{Attribute, Attributes, Color}; - use super::*; -use crate::{ - configs::{ConfigOverride, initialize_config}, - screen::{driver::ScreenDriver, *}, -}; -const CONFIG_OVERRIDE: ConfigOverride = ConfigOverride { - color: None, - out_dir: None, - exit_script: None, -}; -const TERMINAL_SIZE: (u16, u16) = (80, 24); - -macro_rules! setup { - ($sb:ident, $parser:ident, $stdout:ident) => { - initialize_config(CONFIG_OVERRIDE).ok(); - let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); - let mut $sb = ScreenBuffer::new(rect); - let mut $parser = ByteParser::new(); - let mut $stdout = std::io::stdout(); - }; - ($sb:ident, $parser:ident, $config:ident, $stdout:ident) => { - initialize_config(CONFIG_OVERRIDE).ok(); - let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); - let mut $sb = ScreenBuffer::new(rect); - let mut $parser = ByteParser::new(); - let $config = $crate::configs::get_config(); - let mut $stdout = std::io::stdout(); - }; -} - -/// Assert that a line's text contents equal `expected` -/// Usage: -/// `assert_line_eq!(sb, 1, "Hello, world!");` -macro_rules! assert_line_eq { - ($sb:expr, $line_idx:expr, $expected:expr) => {{ - let sb = &$sb; // ScreenBuffer - let line_idx = $line_idx; // which line - let expected: &str = $expected; // expected text - - let line = sb.lines.get(line_idx).expect(&format!( - "No line at index {line_idx}, only {} lines", - sb.lines.len() - )); - let actual: String = line - .iter() - .flat_map(|span| span.iter().map(|c| c.character)) - .collect(); - - if actual.trim_end() != expected { - panic!( - "Line {line_idx} mismatch!\n Expected: {:?}\n Actual: {:?}\n\nFull buffer:\n{}", - expected, - actual, - $crate::screen::process::tests::escape::debug_dump(&sb.lines), - ); - } - }}; -} - -/// Assert that a specific span on a line matches given text and style. -/// Usage: -/// `assert_span_eq!(sb, 1, 0, "Red", fg=Color::DarkRed, bg=Color::Reset);` -#[macro_export] -macro_rules! assert_span_eq { - ( - $sb:expr, // ScreenBuffer - $line_idx:expr, // which line - $span_idx:expr // which span in that line - $(, expected => $expected_text:expr)? - $(, fg => $fg_color:expr)? - $(, bg => $bg_color:expr)? - $(, attrs => $attrs:expr)? - ) => {{ - let sb = &$sb; - let line_idx = $line_idx; - let span_idx = $span_idx; - - let line = sb - .lines - .get(line_idx) - .expect(&format!("No line at index {line_idx}, only {} lines", sb.lines.len())); - let span = line - .iter() - .nth(span_idx) - .expect(&format!("No span at index {span_idx} in line {}", line_idx)); - - // Collect text from cells - $( - let expected_text: &str = $expected_text; - let actual_text: String = span.iter().map(|c| c.character).collect(); - if !actual_text.starts_with(expected_text) { - panic!( - "Span {span_idx} text mismatch!\n Expected: {:?}\n Actual: {:?}\n\nFull buffer:\n{}", - expected_text, - actual_text, - $crate::screen::process::tests::escape::debug_dump(&sb.lines), - ); - } - )? - - // Check optional style args - $( - assert_eq!( - span.colors.foreground, - Some($fg_color), - "Span {} fg color mismatch (expected {:?}, got {:?})\n{}", - span_idx, - $fg_color, - span.colors.foreground, - $crate::screen::process::tests::escape::debug_dump(&sb.lines), - ); - )? - $( - assert_eq!( - span.colors.background, - Some($bg_color), - "Span {} bg color mismatch (expected {:?}, got {:?})\n{}", - span_idx, - $bg_color, - span.colors.background, - $crate::screen::process::tests::escape::debug_dump(&sb.lines), - ); - )? - $( - assert_eq!( - span.attrs, - $attrs, - "Span {} attrs mismatch (expected {:?}, got {:?})\n{}", - span_idx, - $attrs, - span.attrs, - $crate::screen::process::tests::escape::debug_dump(&sb.lines), - ); - )? - }}; -} - -fn debug_dump(lines: &VecDeque) -> String { - let mut out = String::new(); - for (i, line) in lines.iter().enumerate() { - use std::fmt::Write; - - writeln!(&mut out, "Line {i}:").unwrap(); - - for (j, span) in line.iter().enumerate() { - let text: String = span.iter().map(|c| c.character).collect(); - writeln!( - &mut out, - " Span {}: \"{}\" (len = {}, attrs = {:?}, colors = {:?})", - j, - text, - span.len(), - span.attrs, - span.colors - ) - .unwrap(); - } - } - out -} +use crate::{assert_line_eq, assert_span_eq, setup}; #[test] fn single_plain_line() { diff --git a/sericom-core/src/screen/tests/line.rs b/sericom-core/src/screen/tests/line.rs new file mode 100644 index 0000000..324ea6c --- /dev/null +++ b/sericom-core/src/screen/tests/line.rs @@ -0,0 +1,65 @@ +use crate::setup; +use std::fmt::Write; +use std::io; + +use super::*; +use crossterm::execute; +use crossterm::style::Colors; +use crossterm::style::SetAttribute; +use crossterm::style::SetColors; +use crossterm::style::SetForegroundColor; + +// Stole this from crossterm's test - thanks! +#[derive(Default, Debug, Clone)] +struct FakeWrite { + buffer: String, + flushed: bool, +} + +impl io::Write for FakeWrite { + fn write(&mut self, content: &[u8]) -> io::Result { + let content = + str::from_utf8(content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + self.buffer.push_str(content); + self.flushed = false; + Ok(content.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.flushed = true; + Ok(()) + } +} + +#[test] +fn line_as_command() { + initialize_config(CONFIG_OVERRIDE).ok(); + let mut writer = FakeWrite { + buffer: String::new(), + flushed: false, + }; + let mut colors = ColorState::default(); + colors.set_fg(Color::Red); + let mut attrs = Attributes::default(); + attrs.set(Attribute::Bold); + + let span1: Span = "first span".chars().collect(); + let mut span2: Span = "second span".chars().collect(); + span2.set_colors(&colors); + span2.set_attrs(attrs); + let mut span3: Span = "third span\n".chars().collect(); + let line = Line(vec![span1, span2, span3]); + + execute!(writer, line); + + let mut cmp = String::new(); + write!(cmp, "{}", SetColors(Colors::new(Color::Cyan, Color::Reset))); + write!(cmp, "first span"); + write!(cmp, "{}", SetColors(Colors::new(Color::Red, Color::Reset))); + write!(cmp, "{}", SetAttribute(Attribute::Bold)); + write!(cmp, "second span"); + write!(cmp, "{}", SetColors(Colors::new(Color::Cyan, Color::Reset))); + writeln!(cmp, "third span"); + + assert_eq!(writer.buffer, cmp); +} diff --git a/sericom-core/src/screen/tests/mod.rs b/sericom-core/src/screen/tests/mod.rs new file mode 100644 index 0000000..79e2aba --- /dev/null +++ b/sericom-core/src/screen/tests/mod.rs @@ -0,0 +1,166 @@ +mod colors; +mod cursor; +mod escape; +mod line; + +pub use crate::{ + configs::{ConfigOverride, initialize_config}, + screen::{driver::ScreenDriver, *}, +}; +pub use crossterm::style::{Attribute, Attributes, Color}; +pub use std::collections::VecDeque; + +pub const CONFIG_OVERRIDE: ConfigOverride = ConfigOverride { + color: None, + out_dir: None, + exit_script: None, +}; +pub const TERMINAL_SIZE: (u16, u16) = (80, 24); + +#[macro_export] +macro_rules! setup { + ($sb:ident, $parser:ident, $stdout:ident) => { + initialize_config(CONFIG_OVERRIDE).ok(); + let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); + let mut $sb = ScreenBuffer::new(rect); + let mut $parser = ByteParser::new(); + let mut $stdout = std::io::stdout(); + }; + ($sb:ident, $parser:ident, $config:ident, $stdout:ident) => { + initialize_config(CONFIG_OVERRIDE).ok(); + let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); + let mut $sb = ScreenBuffer::new(rect); + let mut $parser = ByteParser::new(); + let $config = $crate::configs::get_config(); + let mut $stdout = std::io::stdout(); + }; +} + +/// Assert that a line's text contents equal `expected` +/// Usage: +/// `assert_line_eq!(sb, 1, "Hello, world!");` +#[macro_export] +macro_rules! assert_line_eq { + ($sb:expr, $line_idx:expr, $expected:expr) => {{ + let sb = &$sb; // ScreenBuffer + let line_idx = $line_idx; // which line + let expected: &str = $expected; // expected text + + let line = sb.lines.get(line_idx).expect(&format!( + "No line at index {line_idx}, only {} lines", + sb.lines.len() + )); + let actual: String = line.iter().flatten().collect(); + + if actual.trim_end() != expected { + panic!( + "Line {line_idx} mismatch!\n Expected: {:?}\n Actual: {:?}\n\nFull buffer:\n{}", + expected, + actual, + $crate::screen::tests::debug_dump(&sb.lines), + ); + } + }}; +} + +/// Assert that a specific span on a line matches given text and style. +/// Usage: +/// `assert_span_eq!(sb, 1, 0, "Red", fg=Color::DarkRed, bg=Color::Reset);` +#[macro_export] +macro_rules! assert_span_eq { + ( + $sb:expr, // ScreenBuffer + $line_idx:expr, // which line + $span_idx:expr // which span in that line + $(, expected => $expected_text:expr)? + $(, fg => $fg_color:expr)? + $(, bg => $bg_color:expr)? + $(, attrs => $attrs:expr)? + ) => {{ + let sb = &$sb; + let line_idx = $line_idx; + let span_idx = $span_idx; + + let line = sb + .lines + .get(line_idx) + .expect(&format!("No line at index {line_idx}, only {} lines", sb.lines.len())); + let span = line + .iter() + .nth(span_idx) + .expect(&format!("No span at index {span_idx} in line {}", line_idx)); + + // Collect text from cells + $( + let expected_text: &str = $expected_text; + let actual_text: String = span.iter().collect(); + if !actual_text.starts_with(expected_text) { + panic!( + "Span {span_idx} text mismatch!\n Expected: {:?}\n Actual: {:?}\n\nFull buffer:\n{}", + expected_text, + actual_text, + $crate::screen::tests::debug_dump(&sb.lines), + ); + } + )? + + // Check optional style args + $( + assert_eq!( + span.colors.foreground, + Some($fg_color), + "Span {} fg color mismatch (expected {:?}, got {:?})\n{}", + span_idx, + $fg_color, + span.colors.foreground, + $crate::screen::tests::debug_dump(&sb.lines), + ); + )? + $( + assert_eq!( + span.colors.background, + Some($bg_color), + "Span {} bg color mismatch (expected {:?}, got {:?})\n{}", + span_idx, + $bg_color, + span.colors.background, + $crate::screen::tests::debug_dump(&sb.lines), + ); + )? + $( + assert_eq!( + span.attrs, + $attrs, + "Span {} attrs mismatch (expected {:?}, got {:?})\n{}", + span_idx, + $attrs, + span.attrs, + $crate::screen::tests::debug_dump(&sb.lines), + ); + )? + }}; +} + +pub fn debug_dump(lines: &VecDeque) -> String { + let mut out = String::new(); + for (i, line) in lines.iter().enumerate() { + use std::fmt::Write; + + writeln!(&mut out, "Line {i}:").unwrap(); + + for (j, span) in line.iter().enumerate() { + let text: String = span.iter().map(|c| c.character).collect(); + writeln!( + &mut out, + " Span {}: \"{}\" (len = {}, attrs = {:?}, colors = {:?})", + j, + text, + span.len(), + span.attrs, + span.colors + ) + .unwrap(); + } + } + out +} diff --git a/sericom-core/src/screen/ui_command.rs b/sericom-core/src/screen/ui_command.rs index 4c4e16e..ec054f6 100644 --- a/sericom-core/src/screen/ui_command.rs +++ b/sericom-core/src/screen/ui_command.rs @@ -120,14 +120,16 @@ impl UIAction for ScreenBuffer { self.lines.clear(); self.view_start = 0; self.set_cursor_pos((0_u16, 0_usize)); - self.lines.push_back(Line::new_default(self.width().into())); + self.lines + .push_back(Line::reserve_new(usize::from(self.width()))); // self.needs_render = true; } /// Clears the current *visible* screen while keeping the buffer's history fn clear_screen(&mut self) { for _ in 0..self.rect.height { - self.lines.push_back(Line::new_default(self.width().into())); + self.lines + .push_back(Line::reserve_new(usize::from(self.width()))); } self.view_start = self.lines.len().saturating_sub(self.rect.height as usize); // self.needs_render = true; diff --git a/sericom-core/src/ui/buffer.rs b/sericom-core/src/ui/buffer.rs index 8c6c577..49c95e7 100644 --- a/sericom-core/src/ui/buffer.rs +++ b/sericom-core/src/ui/buffer.rs @@ -8,9 +8,9 @@ pub struct Buffer { } impl Buffer { pub fn reset(&mut self) { - for line in &mut self.content { - line.reset(); - } + // for line in &mut self.content { + // line.reset(); + // } } #[must_use] pub fn empty(area: Rect) -> Self { @@ -19,7 +19,8 @@ impl Buffer { #[must_use] pub fn filled(area: Rect, span: Span) -> Self { - let line = Line::new(area.width.into(), span); + // let line = Line::new(area.width.into(), span); + let line = Line::reserve_new(area.width.into()); let size = area.height as usize; let content = vec![line; size]; Self { area, content } From 016dbb035800164ea057dac98da3b811e9eae99b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:21:03 +0000 Subject: [PATCH 12/40] chore(deps): bump tracing-appender from 0.2.3 to 0.2.4 Bumps [tracing-appender](https://github.com/tokio-rs/tracing) from 0.2.3 to 0.2.4. - [Release notes](https://github.com/tokio-rs/tracing/releases) - [Commits](https://github.com/tokio-rs/tracing/compare/tracing-appender-0.2.3...tracing-appender-0.2.4) --- updated-dependencies: - dependency-name: tracing-appender dependency-version: 0.2.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ee7187..87bf442 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -733,7 +733,7 @@ dependencies = [ "miette", "serde", "serial2-tokio", - "thiserror 2.0.17", + "thiserror", "tokio", "toml", "tracing", @@ -858,33 +858,13 @@ dependencies = [ "unicode-width 0.2.1", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.17", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -1017,12 +997,12 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 1.0.69", + "thiserror", "time", "tracing-subscriber", ] From 6830d0771097e7af3ea1fafe1a7fcf501ca0f2af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:21:10 +0000 Subject: [PATCH 13/40] chore(deps): bump tracing-subscriber from 0.3.20 to 0.3.21 Bumps [tracing-subscriber](https://github.com/tokio-rs/tracing) from 0.3.20 to 0.3.21. - [Release notes](https://github.com/tokio-rs/tracing/releases) - [Commits](https://github.com/tokio-rs/tracing/compare/tracing-subscriber-0.3.20...tracing-subscriber-0.3.21) --- updated-dependencies: - dependency-name: tracing-subscriber dependency-version: 0.3.21 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 8 ++++---- sericom/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ee7187..783b3a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1040,9 +1040,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -1061,9 +1061,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "bee4bf13715d00789f2a099fd05d127c012bddc5c6628f2c8968374c1820c01d" dependencies = [ "nu-ansi-term", "sharded-slab", diff --git a/sericom/Cargo.toml b/sericom/Cargo.toml index 224301f..6f72e6a 100644 --- a/sericom/Cargo.toml +++ b/sericom/Cargo.toml @@ -31,7 +31,7 @@ rustflags = ["-C", "target-feature=+crt-static"] clap = { version = "4.5.50", features = ["derive"] } sericom-core = { version = "0.7.0", path = "../sericom-core" } tracing-appender = "0.2" -tracing-subscriber = "0.3.20" +tracing-subscriber = "0.3.21" chrono.workspace = true tokio.workspace = true serial2-tokio.workspace = true From 6dc9f7db1a8dd6da53f3e6c3e3e7172e5cee8590 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Wed, 26 Nov 2025 11:34:20 -0600 Subject: [PATCH 14/40] wip: Working on adding back the cell-overwrite functionality changelog: ignore --- sericom-core/src/screen/buffer.rs | 24 ++++++------ sericom-core/src/screen/components/cell.rs | 2 +- sericom-core/src/screen/components/line.rs | 33 ++++++++++------- sericom-core/src/screen/components/span.rs | 14 +++++++ sericom-core/src/screen/driver.rs | 43 ++++++++++++++-------- sericom-core/src/screen/tests/line.rs | 12 ++++++ 6 files changed, 85 insertions(+), 43 deletions(-) diff --git a/sericom-core/src/screen/buffer.rs b/sericom-core/src/screen/buffer.rs index 81ce4aa..04cf837 100644 --- a/sericom-core/src/screen/buffer.rs +++ b/sericom-core/src/screen/buffer.rs @@ -49,7 +49,7 @@ impl ScreenBuffer { max_scrollback: MAX_SCROLLBACK, }; // Start with an empty line - buffer.lines.push_back(Line::reserve_new(rect.width.into())); + buffer.lines.push_back(Line::new_empty(rect.width.into())); buffer } @@ -76,28 +76,26 @@ impl ScreenBuffer { } pub(crate) fn handle_span_colors(&mut self, colors: &super::ColorState, attrs: Attributes) { - let curr_col = self.cursor.x as usize; - let width = self.width() as usize; + let (curr_col, width): (usize, usize); + curr_col = self.cursor.x.into(); + width = self.width().into(); let line = self.curr_line_mut(); - let remainder = width - line.num_cells(); + let remainder = width.saturating_sub(line.last_filled_idx()); line.split_spans(colors, attrs, curr_col, remainder); } - pub(crate) fn with_current_span)>( - &mut self, - f: F, - ) { + pub(crate) fn with_current_span(&mut self, f: F) { let buff_pos = self.to_buff(self.cursor); - let span = { + let (span, offset) = { let line = self.curr_line_mut(); - let (span_idx, _col_offset) = line.span_at_col(buff_pos.x as usize); - line.get_mut_span(span_idx).expect("verified") + let (span_idx, col_offset) = line.span_at_col(buff_pos.x as usize); + (line.get_mut_span(span_idx).expect("verified"), col_offset) }; - f(span, &buff_pos); + f(span, offset); } pub(crate) fn with_current_line)>( @@ -123,7 +121,7 @@ impl ScreenBuffer { .get_mut(pos_in_lines.y as usize) .expect("verified that line exists") } else { - self.push_line(Line::reserve_new(self.width() as usize)); + self.push_line(Line::new_empty(self.width() as usize)); self.lines.back_mut().expect("is not empty") } } diff --git a/sericom-core/src/screen/components/cell.rs b/sericom-core/src/screen/components/cell.rs index 710ce68..e21ce96 100644 --- a/sericom-core/src/screen/components/cell.rs +++ b/sericom-core/src/screen/components/cell.rs @@ -8,7 +8,7 @@ use crate::configs::get_config; /// /// Used to hold rendering state for all the cells within the [`ScreenBuffer`][`super::ScreenBuffer`]. /// Each line within [`ScreenBuffer`][`super::ScreenBuffer`] is represented by a `Vec`. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Cell { pub(crate) character: char, pub(crate) is_selected: bool, diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index 6495c50..1d8d617 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -78,21 +78,22 @@ impl Line { /// The total number of [`Cell`]s, for all [`Span`]s in [`Line`], where the [`Cell`] != [`Cell::EMPTY`]. #[must_use] pub fn num_filled_cells(&self) -> usize { - let mut filled_cells: usize = 0; - self.0.iter().for_each(|span| { - filled_cells += span.num_filled_cells(); - }); - filled_cells + self.iter().flatten().filter(|c| **c != Cell::EMPTY).count() } /// The total number of [`Cell`]s for all [`Span`]s in [`Line`]. #[must_use] pub fn num_cells(&self) -> usize { - let mut num_cells = 0; - self.0.iter().for_each(|span| { - num_cells += span.len(); - }); - num_cells + self.iter().flatten().count() + } + + #[must_use] + pub fn last_filled_idx(&self) -> usize { + self.iter() + .flatten() + .rev() + .position(|c| c.character != ' ') + .unwrap_or(0) } /// Whether [`Line`] contains zero _[`Span`]s_. @@ -123,8 +124,10 @@ impl Line { fill_to: usize, ) { // Handles the case where an ESC[ is the first input for an empty line - if self.len() == 1 && self.num_cells() == 0 { - let mut span = self.get_mut_span(0).expect("verified line.len() == 1"); + if self.len() == 1 + && let Some(span) = self.get_mut_span(0) + && span.is_empty() + { span.set_colors(colors); span.set_attrs(attrs); return; @@ -132,11 +135,13 @@ impl Line { let (span_idx, _) = self.span_at_col(col); match self.get_mut_span(span_idx) { - Some(mut span) => span.shrink(), + Some(mut span) => { + span.shrink(); + } None => self.push(Span::new_empty(fill_to)), } - let span = Span::reserve_new(fill_to, Some(colors.get_colors()), Some(attrs)); + let span = Span::new_empty_colors(fill_to, Some(colors.get_colors()), Some(attrs)); self.push(span); } } diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index 92190d1..3416551 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -61,6 +61,20 @@ impl Span { } } + pub(crate) fn new_empty_colors( + width: usize, + colors: Option, + attrs: Option, + ) -> Self { + let colors = colors.unwrap_or_else(Self::get_config_colors); + let attrs = attrs.unwrap_or_default(); + Self { + cells: vec![Cell::EMPTY; width], + attrs, + colors, + } + } + /// Creates a new [`Span`] and reserves space for `width` of [`Cell`]s. /// /// This calls [`Vec::with_capacity()`] and does not create any [`Cell`]s. diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/driver.rs index 5cc1394..c427457 100644 --- a/sericom-core/src/screen/driver.rs +++ b/sericom-core/src/screen/driver.rs @@ -1,6 +1,10 @@ +use std::fmt::{self, Debug, Write}; + use crossterm::style::Attributes; use tracing::Instrument; +use crate::screen::process::SEP; + use super::ScreenBuffer; use super::components::{Cell, Line, Span}; use super::position::{Cursor, TranslatePos}; @@ -28,7 +32,7 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { pub fn process_events(&mut self, events: Vec) { for ev in events { - eprintln!("{ev}"); + tracing::trace!(target: "parser", event=%ev, "Processing event"); match ev { ParserEvent::Text(bytes) => self.write_text(&bytes), ParserEvent::Control(ctrl) => self.handle_control(ctrl), @@ -38,23 +42,17 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { } fn write_text(&mut self, bytes: &[u8]) { - let chars: Vec = bytes.iter().map(|b| char::from(*b)).collect(); - let num_chars = chars.len(); - - self.buffer.with_current_span(|span, cursor| { - if !span.is_empty() { - span.cells.truncate(cursor.x.into()); - } - - for ch in chars { - span.push(Cell::new(ch)); + self.buffer.with_current_span(|span, offset| { + for (cell, ch) in span.cells.iter_mut().skip(offset).zip(bytes) { + // can cast ch as char because the parse will only pass utf-8 + cell.character = *ch as char; } }); // Truncating is unlikely to happen in this scenario, but even so, // `move_cursor_right` will clamp to `self.width` so its ok. #[allow(clippy::cast_possible_truncation)] - self.buffer.move_cursor_right(num_chars as u16); + self.buffer.move_cursor_right(bytes.len() as u16); } fn handle_control(&mut self, ctrl: u8) { @@ -67,15 +65,17 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { // pushing to the end of line unconditionally because // NL is always the end of a line, and if received a CR // before NL, then `with_current_span` would behave incorrect - if let Some(span) = line.0.last_mut() { - span.push(Cell::NEWLINE); + if let Some(span) = line.0.last_mut() + && let Some(cell) = span.cells.last_mut() + { + cell.character = NL as char; span.shrink(); } }); self.buffer.set_cursor_col(0); self.buffer.move_cursor_down(1); self.buffer - .push_line(Line::reserve_new(self.buffer.width() as usize)); + .push_line(Line::new_empty(self.buffer.width() as usize)); // } // #[cfg(feature = "gui")] // fills span to ScreenBuffer::width() // { @@ -114,6 +114,19 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { fn handle_escape(&mut self, seq: &[u8]) { let Some(seq_type) = classify_escape_seq(seq) else { + let s = seq.iter().fold(String::new(), |mut output, b| { + if *b == ESC { + let _ = write!(output, "ESC"); + } else if *b == BK { + let _ = write!(output, "["); + } else if *b == SEP { + let _ = write!(output, ";"); + } else { + let _ = write!(output, "{}", *b as char); + } + output + }); + tracing::trace!(target: "parser", sequence=%s, "Failed to classify escape sequence"); return; }; match seq_type { diff --git a/sericom-core/src/screen/tests/line.rs b/sericom-core/src/screen/tests/line.rs index 324ea6c..437dd9f 100644 --- a/sericom-core/src/screen/tests/line.rs +++ b/sericom-core/src/screen/tests/line.rs @@ -63,3 +63,15 @@ fn line_as_command() { assert_eq!(writer.buffer, cmp); } + +#[test] +fn overwriting_span() { + let mut span = vec![Cell::EMPTY; 10]; + let chars = vec!['a'; 10]; + + for (cell, ch) in span.iter_mut().skip(5).zip(chars) { + cell.character = ch; + } + + assert_eq!(span, [[Cell::EMPTY; 5], [Cell::new('a'); 5]].concat()); +} From cb6719a5a63aec2ca8fc3258e67dd987f170a245 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Thu, 27 Nov 2025 00:59:05 -0600 Subject: [PATCH 15/40] refactor: Added test-log as dev-dependency for tracing in tests changelog: ignore --- Cargo.lock | 83 +++++++++++++++++++++++++++++++++++++++++ justfile | 9 ++++- sericom-core/Cargo.toml | 3 ++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2bc97da..455d2b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -303,6 +312,27 @@ dependencies = [ "litrs", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -445,6 +475,15 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.7.5" @@ -615,6 +654,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -733,6 +789,7 @@ dependencies = [ "miette", "serde", "serial2-tokio", + "test-log", "thiserror 2.0.17", "tokio", "toml", @@ -848,6 +905,28 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "test-log" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" +dependencies = [ + "env_logger", + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "test-sericom" version = "0.1.0" @@ -1069,10 +1148,14 @@ version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/justfile b/justfile index c4758da..44a741e 100644 --- a/justfile +++ b/justfile @@ -1,9 +1,11 @@ alias l := lint alias c := check alias t := test +alias tt := test-trace alias b := build alias br := build-release +default-trace := 'sericom-core' default-tests := '' default: check lint @@ -41,5 +43,8 @@ testseri: run-trace: cargo run -- /dev/ttyUSB0 -d -test target=default-tests: - cargo test {{target}} --no-fail-fast --lib +test tests=default-tests: + cargo test {{tests}} --no-fail-fast --lib + +test-trace target=default-trace tests=default-tests: + RUST_LOG='{{target}}=trace' cargo test {{tests}} --no-fail-fast --lib -- --nocapture diff --git a/sericom-core/Cargo.toml b/sericom-core/Cargo.toml index 8188438..f159787 100644 --- a/sericom-core/Cargo.toml +++ b/sericom-core/Cargo.toml @@ -24,6 +24,9 @@ serial2-tokio.workspace = true tokio.workspace = true tracing.workspace = true +[dev-dependencies] +test-log = {version = "0.2.18", default-features = false, features = ["trace", "color"]} + [lints.clippy] pedantic = "warn" perf = "warn" From c274d2cfff4923a10308b99b0d02b53c7e5c2aa0 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Thu, 27 Nov 2025 01:02:39 -0600 Subject: [PATCH 16/40] refactor(rendering): Implemented overwriting vs pushing to Line Most of the underlying parsing/handling logic is completed, if not all. There are a _few_ things I'm not quite sure about yet but I'm waiting to see how it plays out after hooking up the rendering and being able to see results/behavior. All of the tests are passing, added a few more to verify logic in a few spots. Cursor movement, color parsing, line overwriting and span management/ splitting all seem to be functioning correctly. I believe the majority of what is left to do is directly related to the actual rendering to the terminal. Along with figuring out double bufferring and how to write it up to enable tui 'popups' (like a menu for session management). Some things to keep in mind: - Still need to track the `ScreenBuffer::view_start` with lines - After lines are 'out of view', they should be truncated (trim whitespace/empty cells) since they won't be written to anymore and truncating will help with their memory size in the VecDeque - While hooking up the rendering logic, `serial_actor/*.rs` should be re-evaluated and refactored - currently looks like a mess. Would be nice to split the task functions up where possible to avoid big Future structs on heap. - Switch to crossbeam for channels? Not sure how they play w/ Tokio - Same with parking_lot and mutexes or maybe RwLock where fitting. Been awhile since I looked at that code so I don't remember what it is or isn't using. - Should spend a day pumping out tests for all of the core functionality to get a baseline - Hook up `test-sericom` so that testing logic can be a little more real-world and longer-running 'sessions' that can be programatically run and verified. Lastly, this won't be relevant for a while yet, but still something to keep thinking about: how and where can `sericom-core` be split up behind cargo features to enable usage across different frontends i.e. CLI || GUI changelog: ignore --- sericom-core/src/screen/buffer.rs | 13 +- sericom-core/src/screen/components/cell.rs | 10 +- sericom-core/src/screen/components/line.rs | 118 +++++++++------- sericom-core/src/screen/components/span.rs | 133 +++++++++++------- sericom-core/src/screen/driver.rs | 76 +++------- sericom-core/src/screen/mod.rs | 6 +- sericom-core/src/screen/position.rs | 13 +- sericom-core/src/screen/process/colors.rs | 17 +-- sericom-core/src/screen/process/cursor.rs | 2 +- sericom-core/src/screen/process/mod.rs | 4 +- sericom-core/src/screen/process/parser.rs | 2 +- sericom-core/src/screen/process/screen.rs | 10 +- .../screen/tests/{line.rs => components.rs} | 52 +++++++ sericom-core/src/screen/tests/cursor.rs | 6 +- sericom-core/src/screen/tests/escape.rs | 2 +- sericom-core/src/screen/tests/mod.rs | 2 +- sericom-core/src/screen/ui_command.rs | 6 +- sericom-core/src/ui/buffer.rs | 2 +- 18 files changed, 268 insertions(+), 206 deletions(-) rename sericom-core/src/screen/tests/{line.rs => components.rs} (61%) diff --git a/sericom-core/src/screen/buffer.rs b/sericom-core/src/screen/buffer.rs index 04cf837..7efb940 100644 --- a/sericom-core/src/screen/buffer.rs +++ b/sericom-core/src/screen/buffer.rs @@ -67,23 +67,19 @@ impl ScreenBuffer { pub fn save_cursor_pos(&mut self, stdout: &mut W) { use crossterm::cursor::SavePosition; - crossterm::execute!(stdout, SavePosition); + let _ = crossterm::execute!(stdout, SavePosition); } pub fn restore_cursor_pos(&mut self, stdout: &mut W) { use crossterm::cursor::RestorePosition; - crossterm::execute!(stdout, RestorePosition); + let _ = crossterm::execute!(stdout, RestorePosition); } pub(crate) fn handle_span_colors(&mut self, colors: &super::ColorState, attrs: Attributes) { - let (curr_col, width): (usize, usize); - curr_col = self.cursor.x.into(); - width = self.width().into(); + let curr_col = self.cursor.x.into(); let line = self.curr_line_mut(); - let remainder = width.saturating_sub(line.last_filled_idx()); - - line.split_spans(colors, attrs, curr_col, remainder); + line.split_spans(colors, attrs, curr_col); } pub(crate) fn with_current_span(&mut self, f: F) { @@ -103,7 +99,6 @@ impl ScreenBuffer { f: F, ) { let buff_pos = self.to_buff(self.cursor); - eprintln!("buffer pos: {buff_pos:?}"); let line = self.curr_line_mut(); f(line, &buff_pos); diff --git a/sericom-core/src/screen/components/cell.rs b/sericom-core/src/screen/components/cell.rs index e21ce96..a43a296 100644 --- a/sericom-core/src/screen/components/cell.rs +++ b/sericom-core/src/screen/components/cell.rs @@ -1,9 +1,5 @@ use std::ops::{Deref, DerefMut}; -use crossterm::style::Color; - -use crate::configs::get_config; - /// `Cell` represents a cell within the terminal's window/frame. /// /// Used to hold rendering state for all the cells within the [`ScreenBuffer`][`super::ScreenBuffer`]. @@ -71,3 +67,9 @@ impl Default for Cell { } } } + +impl<'a> FromIterator<&'a Cell> for std::string::String { + fn from_iter>(iter: T) -> Self { + iter.into_iter().map(Deref::deref).collect::() + } +} diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index 1d8d617..370d62b 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -1,37 +1,27 @@ -use std::fmt::Display; use std::ops::{Index, IndexMut}; -use crossterm::style::{Attributes, Colors, SetAttributes, SetColors}; +use crossterm::style::{Attributes, SetAttributes, SetColors}; use crate::screen::ColorState; -use crossterm::csi; -use super::Cell; use super::Span; -/// Line is a wrapper around [`Vec`] and represents a line within the [`ScreenBuffer`][`super::ScreenBuffer`]. -#[derive(Debug, Clone, Eq, PartialEq, Default)] +/// Line is a wrapper around [`Vec`] and represents a line within the [`ScreenBuffer`][`super::super::ScreenBuffer`]. +#[derive(Clone, Eq, PartialEq, Default)] pub struct Line(pub Vec); impl Line { - /// Create a new line with a single [`Span`] with the length/size of `width`. + /// Create a new [`Line`] with a single [`Span`] with the length of `width`. /// - /// The [`Span`] is filled with [`Cell::EMPTY`]. + /// The [`Span`] is filled with [`Cell::EMPTY`], default [`Attributes`] and [`Colors`]. #[must_use] pub fn new_empty(width: usize) -> Self { - Self(vec![Span::new_empty(width); 1]) + Self(vec![Span::new_empty(width, None, None); 1]) } - /// Create a new line with a single [`Span`] reserved with a capacity of - /// `width`, does not fill [`Self`] with any [`Cell`]s, just reserves space. + /// Util function to return the length of `self`. /// - /// [`Cell`]: crate::ui::Cell - #[must_use] - pub fn reserve_new(width: usize) -> Self { - Self(vec![Span::reserve_new(width, None, None); 1]) - } - - /// Util function to return the length of [`Self`]. + /// **Note:** This returns the length as in the number of [`Span`]s _not_ [`Cell`]s. #[must_use] #[allow(clippy::len_without_is_empty)] pub const fn len(&self) -> usize { @@ -46,13 +36,13 @@ impl Line { .for_each(|cell| cell.is_selected = false); } - /// Returns a reference to [`Span`] at `idx`. + /// Returns a reference to the [`Span`] at `idx`. #[must_use] pub fn get_span(&self, idx: usize) -> Option<&Span> { self.0.get(idx) } - /// Returns a mutable reference to [`Span`] at `idx`. + /// Returns a mutable reference to the [`Span`] at `idx`. pub fn get_mut_span(&mut self, idx: usize) -> Option<&mut Span> { self.0.get_mut(idx) } @@ -75,25 +65,32 @@ impl Line { (self.0.len().saturating_sub(1), self.0.last().unwrap().len()) } - /// The total number of [`Cell`]s, for all [`Span`]s in [`Line`], where the [`Cell`] != [`Cell::EMPTY`]. + /// The total number of [`Cell`]s in `self`, where [`Cell`] != ' '. + /// + /// **Note:** This number includes whitespace between words since that is + /// generally the desired behavior. #[must_use] - pub fn num_filled_cells(&self) -> usize { - self.iter().flatten().filter(|c| **c != Cell::EMPTY).count() + pub fn filled_cells(&self) -> usize { + let last = self.last_filled_idx() + 1; + if self.num_cells() == last { 0 } else { last } } - /// The total number of [`Cell`]s for all [`Span`]s in [`Line`]. + /// The total number of [`Cell`]s in `self`. #[must_use] pub fn num_cells(&self) -> usize { self.iter().flatten().count() } + /// The index of the last [`Cell`] in `self` where [`Cell`] != ' ' (whitespace). #[must_use] pub fn last_filled_idx(&self) -> usize { - self.iter() - .flatten() - .rev() - .position(|c| c.character != ' ') - .unwrap_or(0) + (self.num_cells() - 1) + - self + .iter() + .flatten() + .rev() + .position(|c| c.character != ' ') + .unwrap_or(0) } /// Whether [`Line`] contains zero _[`Span`]s_. @@ -110,39 +107,38 @@ impl Line { self.0.iter_mut() } + /// Push a [`Span`] to `self`. pub fn push(&mut self, span: Span) { self.0.push(span); } - /// Shrinks the span at `col` to `span.len()` and creates a new span with - /// capacity of `fill_to`, `colors` and `attrs`. See [`Span::reserve_new`]. - pub fn split_spans( - &mut self, - colors: &ColorState, - attrs: Attributes, - col: usize, - fill_to: usize, - ) { + /// Splits the [`Span`] at `col` and applies `colors` && `attrs` to the new [`Span`]. + /// + /// If there is only a single [`Span`] in `self` and [`Self::num_filled_cells()`] == 0 + /// then this will not split the [`Span`] or create a new one. Instead it will + /// just apply the [`ColorState`] and [`Attributes`] to that [`Span`]. + pub fn split_spans(&mut self, colors: &ColorState, attrs: Attributes, col: usize) { // Handles the case where an ESC[ is the first input for an empty line if self.len() == 1 + && self.filled_cells() == 0 && let Some(span) = self.get_mut_span(0) - && span.is_empty() { span.set_colors(colors); span.set_attrs(attrs); + tracing::trace!(target: "line::split::first", ?span); return; } - let (span_idx, _) = self.span_at_col(col); - match self.get_mut_span(span_idx) { - Some(mut span) => { - span.shrink(); - } - None => self.push(Span::new_empty(fill_to)), - } - - let span = Span::new_empty_colors(fill_to, Some(colors.get_colors()), Some(attrs)); - self.push(span); + let (span_idx, offset) = self.span_at_col(col); + let Some(span) = self.get_mut_span(span_idx) else { + tracing::trace!(target: "line::split", %col, %span_idx, "FAILED TO GET SPAN"); + return; + }; + tracing::trace!(target: "line::split::rest", ?span); + let mut new = span.split_at(offset); + new.set_colors(colors); + new.set_attrs(attrs); + self.push(new); } } @@ -188,7 +184,6 @@ impl IndexMut for Line { impl crossterm::Command for Line { fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { - use crossterm::style::PrintStyledContent; let mut spans = self.iter(); let Some(first) = spans.next() else { return Ok(()); @@ -219,6 +214,20 @@ impl crossterm::Command for Line { } } +impl std::fmt::Debug for Line { + /// Prints "Line [cells: {#}] ( {spans} )" + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::fmt::Write; + + let mut s = format!("Line [cells: {}] ( ", self.num_cells()); + for span in self { + let _ = write!(s, "{span:?}"); + } + s.push_str(" )"); + f.write_str(&s) + } +} + // impl Line { // /// Create a new line with the length/size of `width`. // /// @@ -235,6 +244,15 @@ impl crossterm::Command for Line { // }); // } +// /// Create a new line with a single [`Span`] reserved with a capacity of +// /// `width`, does not fill [`Self`] with any [`Cell`]s, just reserves space. +// /// +// /// [`Cell`]: crate::ui::Cell +// #[must_use] +// pub fn reserve_new(width: usize) -> Self { +// Self(vec![Span::reserve_new(width, None, None); 1]) +// } + // /// Iterates over the [`Cell`]s to index `idx` within [`Self`] // /// and sets them to [`Cell::default()`]. // pub fn reset_to(&mut self, idx: usize) { diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index 3416551..ef0cbfa 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -1,16 +1,16 @@ -use std::{ - fmt::Formatter, - ops::{Deref, Index, IndexMut}, -}; +use std::ops::{Index, IndexMut}; use crossterm::{ Command, - style::{Attribute, Attributes, Color, Colors, ContentStyle, StyledContent, Stylize}, + style::{Attribute, Attributes, Color, Colors}, }; -use crate::{configs::get_config, screen::Cell, screen::process::ColorState}; +use crate::{ + configs::get_config, + screen::{Cell, process::ColorState}, +}; -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct Span { pub(crate) cells: Vec, pub(crate) attrs: Attributes, @@ -29,6 +29,28 @@ impl Default for Span { } impl Span { + /// Splits `self` [0, idx) and returns a new [`Span`] [idx, len). + /// + /// The returned [`Span`] has the same properties (colors and attributes) as `self`. + pub(crate) fn split_at(&mut self, idx: usize) -> Self { + Self { + cells: self.cells.split_off(idx), + colors: self.colors, + attrs: self.attrs, + } + } + + /// The index of the last [`Cell`] in `self` that is not whitespace. + #[must_use] + pub fn last_filled_idx(&self) -> usize { + self.len() + - self + .iter() + .rev() + .position(|c| c.character != ' ') + .unwrap_or(0) + } + fn get_config_colors() -> Colors { let config = get_config(); let fg = Color::from(&config.appearance.fg); @@ -36,14 +58,22 @@ impl Span { Colors::new(fg, bg) } + /// Set the [`Attributes`] for `self`. pub(crate) const fn set_attrs(&mut self, attrs: Attributes) { self.attrs = attrs; } + /// Add an [`Attribute`] to [`Self`], if already set this does nothing. pub(crate) fn add_attr(&mut self, attr: Attribute) { self.attrs.set(attr); } + /// Reset all [`Cell`]s within `self`. + /// + /// Sets every [`Cell`] in [`Self`] to [`Cell::EMPTY`], sets [`Attributes`] + /// to [`Attributes::default()`] and [`Colors`] to those from [`Config::appearance`]. + /// + /// [`Config::appearance`]: `crate::configs::Config::appearance` pub(crate) fn reset(&mut self) { self.cells.iter_mut().for_each(|cell| { *cell = Cell::EMPTY; @@ -52,20 +82,11 @@ impl Span { self.colors = Self::get_config_colors(); } - pub(crate) fn new_empty(width: usize) -> Self { - let colors = Self::get_config_colors(); - Self { - cells: vec![Cell::EMPTY; width], - attrs: Attributes::default(), - colors, - } - } - - pub(crate) fn new_empty_colors( - width: usize, - colors: Option, - attrs: Option, - ) -> Self { + /// Creates a new [`Span`] filled with [`Cell::EMPTY`] to `width`. + /// + /// Create the [`Span`] with [`Colors`] and/or [`Attributes`]. If `None`, + /// uses colors from config file ([`get_config()`]) and [`Attributes::default()`]. + pub fn new_empty(width: usize, colors: Option, attrs: Option) -> Self { let colors = colors.unwrap_or_else(Self::get_config_colors); let attrs = attrs.unwrap_or_default(); Self { @@ -94,23 +115,35 @@ impl Span { } } + /// Resizes `self` to `width` with [`Cell::EMPTY`]. pub(crate) fn fill_to_width(&mut self, width: usize) { self.cells.resize(width, Cell::EMPTY); } - pub(crate) fn shrink(&mut self) { - let size = self.cells.len(); + /// Shrinks `self` to the last filled [`Cell`], think [`str::trim_end()`]. + /// + /// Finds the last [`Cell`] in `self` that is not whitespace and truncates + /// `self` to that index, dropping any [`Cell`]s past it, and then calls + /// [`shrink_to()`] that same index to then truncate `self`s capacity. + /// + /// [`shrink_to()`]: `Vec::shrink_to()` + pub fn shrink(&mut self) { + let size = self.last_filled_idx(); + self.cells.truncate(size); self.cells.shrink_to(size); } + /// Push a [`Cell`] to `self`. pub(crate) fn push(&mut self, cell: Cell) { self.cells.push(cell); } + /// Length of [`Cell`]s in `self`. pub(crate) const fn len(&self) -> usize { self.cells.len() } + /// Set the [`Colors`] for `self`. pub(crate) const fn set_colors(&mut self, colors: &ColorState) { self.colors = colors.get_colors(); } @@ -127,24 +160,6 @@ impl Span { self.cells.iter_mut() } - /// Returns the number of [`Cell`]s in a [`Span`] that are not [`Cell::EMPTY`] - #[must_use] - pub fn num_filled_cells(&self) -> usize { - self.cells - .iter() - .filter(|&cell| *cell != Cell::EMPTY) - .count() - } - - const fn content_style(&self) -> ContentStyle { - ContentStyle { - foreground_color: self.colors.foreground, - background_color: self.colors.background, - underline_color: None, - attributes: self.attrs, - } - } - pub(crate) const fn colors(&self) -> Colors { self.colors } @@ -152,11 +167,6 @@ impl Span { pub(crate) const fn attrs(&self) -> Attributes { self.attrs } - - pub(crate) fn styled(&self) -> StyledContent { - let s = String::from_iter(&self.cells); - self.content_style().apply(s) - } } impl Command for Span { @@ -216,8 +226,33 @@ impl FromIterator for Span { } } -impl<'a> FromIterator<&'a Cell> for std::string::String { - fn from_iter>(iter: T) -> Self { - iter.into_iter().map(Deref::deref).collect::() +impl FromIterator for Span { + fn from_iter>(iter: T) -> Self { + let colors = Self::get_config_colors(); + Self { + cells: iter.into_iter().collect(), + colors, + attrs: Attributes::default(), + } + } +} + +impl std::fmt::Debug for Span { + /// Prints "Span[fg: {:?}, bg: {:?}, attrs: {:?}, len: {}, cap: {}] ( {cells} )" + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::fmt::Write; + use std::ops::Deref; + + let mut s = format!( + "Span[fg: {:?}, bg: {:?}, attrs: {:?}, len: {}, cap: {}] ( ", + self.colors.foreground.unwrap(), + self.colors.background.unwrap(), + self.attrs, + self.cells.len(), + self.cells.capacity() + ); + s.extend(self.cells.iter().map(Deref::deref)); + s.push(')'); + f.write_str(&s) } } diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/driver.rs index c427457..33135a9 100644 --- a/sericom-core/src/screen/driver.rs +++ b/sericom-core/src/screen/driver.rs @@ -1,21 +1,24 @@ -use std::fmt::{self, Debug, Write}; +use std::fmt::Write; use crossterm::style::Attributes; -use tracing::Instrument; use crate::screen::process::SEP; use super::ScreenBuffer; -use super::components::{Cell, Line, Span}; -use super::position::{Cursor, TranslatePos}; +use super::components::{Cell, Line}; +use super::position::Cursor; use super::process::{BK, BS, CR, ColorState, ESC, FF, NL, ParserEvent, TAB}; -use super::{process_colors, process_cursor, process_erase, process_screen}; +use super::{process_colors, process_cursor, process_erase}; /// The layer between incoming [`ParserEvent`]s and the [`ScreenBuffer`]. pub struct ScreenDriver<'a, W: std::io::Write> { buffer: &'a mut ScreenBuffer, color_state: ColorState, attrs: Attributes, + // TODO: Figure out if this is necessary, implemented Command for Line and + // Span, so the only thing I can think that this would be useful for is + // clearing the screen. + // // stdout gives access to call crossterm::execute!/queue! stdout: &'a mut W, } @@ -31,9 +34,9 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { } pub fn process_events(&mut self, events: Vec) { - for ev in events { - tracing::trace!(target: "parser", event=%ev, "Processing event"); - match ev { + for event in events { + tracing::trace!(target: "parser::events", %event); + match event { ParserEvent::Text(bytes) => self.write_text(&bytes), ParserEvent::Control(ctrl) => self.handle_control(ctrl), ParserEvent::EscapeSequence(seq) => self.handle_escape(&seq), @@ -44,7 +47,7 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { fn write_text(&mut self, bytes: &[u8]) { self.buffer.with_current_span(|span, offset| { for (cell, ch) in span.cells.iter_mut().skip(offset).zip(bytes) { - // can cast ch as char because the parse will only pass utf-8 + // can cast ch as char because the parser will only pass utf-8 cell.character = *ch as char; } }); @@ -59,38 +62,23 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { match ctrl { BS => self.buffer.move_cursor_left(1), NL => { - // #[cfg(feature = "cli")] - // { self.buffer.with_current_line(|line, _| { // pushing to the end of line unconditionally because // NL is always the end of a line, and if received a CR // before NL, then `with_current_span` would behave incorrect - if let Some(span) = line.0.last_mut() - && let Some(cell) = span.cells.last_mut() - { - cell.character = NL as char; - span.shrink(); + if let Some(span) = line.0.last_mut() { + let last = span.last_filled_idx(); + span.cells + .get_mut(last) + .expect("span len is greater than last filled cell") + .character = '\n'; } + tracing::trace!(target: "parser::newline", ?line); }); self.buffer.set_cursor_col(0); self.buffer.move_cursor_down(1); self.buffer .push_line(Line::new_empty(self.buffer.width() as usize)); - // } - // #[cfg(feature = "gui")] // fills span to ScreenBuffer::width() - // { - // let remainder = self.buffer.width() as usize - // - (self.buffer.curr_line().map_or_else(|| 0, Line::num_cells)); - // if remainder != 0 { - // self.buffer.with_current_span(|span, _| { - // let width = remainder + span.cells.len(); - // span.fill_to_width(width); - // }); - // } - // self.buffer - // .push_line(Line::reserve_new(self.buffer.width() as usize)); - // self.buffer.set_cursor_col(0); - // } } TAB => { self.buffer.with_current_span(|span, _| { @@ -98,13 +86,7 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { }); self.buffer.cursor.tab(); } - CR => { - self.buffer.with_current_span(|span, _| { - span.push(Cell::CARRIGE); - span.shrink(); - }); - self.buffer.set_cursor_col(0); - } + CR => self.buffer.set_cursor_col(0), // Not sure that FF needs to be handled #[allow(clippy::match_same_arms)] FF => {} @@ -121,12 +103,11 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { let _ = write!(output, "["); } else if *b == SEP { let _ = write!(output, ";"); - } else { - let _ = write!(output, "{}", *b as char); } + let _ = write!(output, "{}", *b as char); output }); - tracing::trace!(target: "parser", sequence=%s, "Failed to classify escape sequence"); + tracing::debug!(target: "parser::escape", sequence=%s, "Failed to classify escape sequence"); return; }; match seq_type { @@ -156,18 +137,7 @@ pub enum EscSequenceType { Screen(u8), } -impl std::fmt::Display for EscSequenceType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Cursor(b) => todo!(), - Self::Erase(b) => todo!(), - Self::Graphics => todo!(), - Self::Screen(b) => todo!(), - } - } -} - -pub(crate) fn classify_escape_seq(seq: &[u8]) -> Option { +pub fn classify_escape_seq(seq: &[u8]) -> Option { // https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 // Ensures the sequence resembles: ESC[ if seq.len() < 3 || seq[0] != ESC || seq[1] != BK { diff --git a/sericom-core/src/screen/mod.rs b/sericom-core/src/screen/mod.rs index a86cc8e..2bc5056 100644 --- a/sericom-core/src/screen/mod.rs +++ b/sericom-core/src/screen/mod.rs @@ -16,7 +16,7 @@ //! connection in a [`VecDeque`]. It is important to note that //! currently, the **capacity of the [`VecDeque`] is hardcoded with a value of 10,000 //! lines with [`MAX_SCROLLBACK`]**. -#![deny(dead_code)] +#![allow(dead_code)] #![allow(unused)] mod buffer; @@ -38,6 +38,4 @@ pub use ui_command::{UIAction, UICommand}; #[cfg(test)] pub(crate) mod tests; -pub(in crate::screen) use process::{ - process_colors, process_cursor, process_erase, process_screen, -}; +pub(in crate::screen) use process::{process_colors, process_cursor, process_erase}; diff --git a/sericom-core/src/screen/position.rs b/sericom-core/src/screen/position.rs index 1130491..10261f0 100644 --- a/sericom-core/src/screen/position.rs +++ b/sericom-core/src/screen/position.rs @@ -1,7 +1,5 @@ use std::{fmt::Display, marker::PhantomData}; -use crate::screen::Line; - use super::Rect; use super::ScreenBuffer; @@ -55,6 +53,7 @@ impl Position { pub const fn tab(&mut self) { let tab_width = 8; let tab = tab_width - (self.x % tab_width); + self.x += tab; } pub const fn set_y(&mut self, y: PosY) { self.y = y; @@ -125,7 +124,7 @@ macro_rules! impl_from { impl From<$from> for $for { fn from((x, y): $from) -> Self { Self { - x: <$as>::try_from(y).expect("usize is within the width of terminal"), + x: <$as>::try_from(x).expect("usize is within the width of terminal"), y, _phantom: PhantomData, } @@ -141,6 +140,7 @@ impl_from!((usize, u16), Position, x_as = u16); // -- BuffPos --// impl_from!((u16, u32), Position); +impl_from!((u16, usize), Position, y_as = u32); pub trait HasBounds { type Bounds; @@ -221,7 +221,7 @@ impl Cursor for ScreenBuffer { fn move_cursor_down(&mut self, lines: u16) { let bounds = self.bounds(); - let mut new_y = self.cursor.y.saturating_add(lines); + let new_y = self.cursor.y.saturating_add(lines); // TODO: FIGURE OUT LINE PUSHING if new_y <= bounds.bottom() { @@ -267,9 +267,8 @@ impl TranslatePos for ScreenBuffer { } fn to_buff(&self, pos: Position) -> Position { - let buff_y = (u32::try_from(self.view_start) - .expect("ScreenBuffer is less than usize::MAX") - + u32::from(pos.y)); + let buff_y = u32::try_from(self.view_start).expect("ScreenBuffer is less than usize::MAX") + + u32::from(pos.y); let buff_x = pos.x.clamp(0, self.width()); Position::::from((buff_x, buff_y)) diff --git a/sericom-core/src/screen/process/colors.rs b/sericom-core/src/screen/process/colors.rs index 71162af..209e2e8 100644 --- a/sericom-core/src/screen/process/colors.rs +++ b/sericom-core/src/screen/process/colors.rs @@ -43,15 +43,18 @@ impl ColorState { self.colors = Colors::new(Color::Reset, Color::Reset); } pub fn set_colors(&mut self, ascii_str: &str) { + use tracing::debug; + self.colors = if let Some(colored) = Colored::parse_ansi(ascii_str) { eprintln!("{colored:#?}"); self.colors.then(&colored.into()) } else { - match Color::parse_ansi(ascii_str) { - Some(color) => eprintln!("Second try got: {color:#?}"), - None => eprintln!("Failed to parse ascii_str second time"), + if let Some(color) = Color::parse_ansi(ascii_str) { + debug!(target: "parser::colors", "Second try got: {color:#?}"); + } else { + debug!("Failed to parse ascii_str second time"); } - eprintln!("Failed to parse ascii_str"); + debug!(target: "parser::colors", "Failed to parse ascii_str"); self.colors }; } @@ -76,9 +79,6 @@ pub fn process_colors(seq: &[u8], color_state: &mut ColorState, attrs: &mut Attr let part_str_len = part.len() + 1 + ident.len() + 1 + color.len(); let slice = &body[body_idx..body_idx + part_str_len]; - #[cfg(test)] - eprintln!("slice: {}", str::from_utf8(slice).unwrap()); - if let Ok(s) = std::str::from_utf8(slice) { color_state.set_colors(s); } @@ -95,9 +95,6 @@ pub fn process_colors(seq: &[u8], color_state: &mut ColorState, attrs: &mut Attr part.len() + 1 + ident.len() + 1 + r.len() + 1 + g.len() + 1 + b.len(); let slice = &body[body_idx..body_idx + part_str_len]; - #[cfg(test)] - eprintln!("slice: {}", str::from_utf8(slice).unwrap()); - if let Ok(s) = std::str::from_utf8(slice) { color_state.set_colors(s); } diff --git a/sericom-core/src/screen/process/cursor.rs b/sericom-core/src/screen/process/cursor.rs index 41d50e2..da483b4 100644 --- a/sericom-core/src/screen/process/cursor.rs +++ b/sericom-core/src/screen/process/cursor.rs @@ -1,5 +1,5 @@ use crate::screen::{ - ScreenBuffer, TermPos, + ScreenBuffer, position::{Cursor, Position}, process::SEP, }; diff --git a/sericom-core/src/screen/process/mod.rs b/sericom-core/src/screen/process/mod.rs index 2a54faf..6d26626 100644 --- a/sericom-core/src/screen/process/mod.rs +++ b/sericom-core/src/screen/process/mod.rs @@ -6,9 +6,7 @@ mod screen; pub use colors::{ColorState, process_colors}; pub use cursor::process_cursor; pub use parser::{ByteParser, ParseState, ParserEvent}; -pub use screen::{process_erase, process_screen}; - -pub(crate) use parser::*; +pub use screen::process_erase; /// Bracket '[' pub(crate) const BK: u8 = b'['; diff --git a/sericom-core/src/screen/process/parser.rs b/sericom-core/src/screen/process/parser.rs index ca05f92..e489709 100644 --- a/sericom-core/src/screen/process/parser.rs +++ b/sericom-core/src/screen/process/parser.rs @@ -16,7 +16,7 @@ impl std::fmt::Display for ParserEvent { let s = str::from_utf8(items).expect("parsed text is utf-8"); f.write_fmt(format_args!("Text( {s} )")) } - Self::Control(b) => f.write_fmt(format_args!("Control( {b:#X} )")), + Self::Control(b) => f.write_fmt(format_args!("Control( {b:#02X} )")), Self::EscapeSequence(items) => { let Some(etype) = classify_escape_seq(items) else { return f.write_str("INVALID ESC SEQ"); diff --git a/sericom-core/src/screen/process/screen.rs b/sericom-core/src/screen/process/screen.rs index 4d1815c..c96d790 100644 --- a/sericom-core/src/screen/process/screen.rs +++ b/sericom-core/src/screen/process/screen.rs @@ -3,7 +3,7 @@ // execute, // terminal::{Clear, ClearType}, // }; -use std::ops::{Range, RangeInclusive}; +use std::ops::Range; use crate::screen::{Line, Position, ScreenBuffer, TermPos, TranslatePos}; @@ -11,7 +11,7 @@ pub fn process_erase( seq: &[u8], kind: u8, sb: &mut ScreenBuffer, - stdout: &mut W, + _stdout: &mut W, ) { let body = &seq[2..seq.len() - 1]; @@ -71,7 +71,7 @@ pub fn process_erase( // #[cfg(feature = "gui")] // { sb.with_current_line(|line, _| { - line.iter_mut().flatten().for_each(|mut cell| { + line.iter_mut().flatten().for_each(|cell| { cell.character = ' '; }); }); @@ -113,8 +113,8 @@ impl ScreenBuffer { } } -pub fn process_screen(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { - let body = &seq[2..seq.len() - 1]; +pub fn _process_screen(seq: &[u8], _kind: u8, _sb: &mut ScreenBuffer) { + let _body = &seq[2..seq.len() - 1]; // ESC[={value}h Changes the screen width or type to the mode specified by value todo!() } diff --git a/sericom-core/src/screen/tests/line.rs b/sericom-core/src/screen/tests/components.rs similarity index 61% rename from sericom-core/src/screen/tests/line.rs rename to sericom-core/src/screen/tests/components.rs index 437dd9f..e454129 100644 --- a/sericom-core/src/screen/tests/line.rs +++ b/sericom-core/src/screen/tests/components.rs @@ -75,3 +75,55 @@ fn overwriting_span() { assert_eq!(span, [[Cell::EMPTY; 5], [Cell::new('a'); 5]].concat()); } + +#[test] +fn span_newline() { + initialize_config(CONFIG_OVERRIDE); + + let mut span: Span = "my test span ".chars().collect(); + let res = span.last_filled_idx(); + assert_eq!(12, res); + { + let c = span + .cells + .get_mut(res) + .expect("span len is greater than last filled cell"); + c.character = '\n'; + } + + let res = span.last_filled_idx(); + assert_eq!(13, res); + + span.shrink(); + + assert_eq!(span.cells.capacity(), 13); + assert_eq!(span.cells.len(), 13); +} + +#[test] +fn line_last_filled() { + initialize_config(CONFIG_OVERRIDE); + + let mut span1: Span = "first span".chars().collect(); + let mut span2: Span = "second span ".chars().collect(); + let line = Line(vec![span1, span2]); + + assert_eq!(line.last_filled_idx(), 20); + assert_eq!(line.iter().flatten().nth(20), Some(&Cell::new('n'))); +} + +#[test] +fn line_num_filled() { + initialize_config(CONFIG_OVERRIDE); + + let mut span1: Span = "first span".chars().collect(); + let mut span2: Span = "second span ".chars().collect(); + let line = Line(vec![span1, span2]); + + assert_eq!(line.filled_cells(), 21); + + let mut span: Span = " ".chars().collect(); + let line = Line(vec![span]); + + assert_eq!(line.filled_cells(), 0); +} diff --git a/sericom-core/src/screen/tests/cursor.rs b/sericom-core/src/screen/tests/cursor.rs index 671d1ac..6fd67e5 100644 --- a/sericom-core/src/screen/tests/cursor.rs +++ b/sericom-core/src/screen/tests/cursor.rs @@ -22,7 +22,7 @@ const RESET: &[u8] = &[ESC, BK, b'0', b'm']; #[test] fn clear_line_from_cursor() { - setup!(sb, parser, config, stdout); + setup!(sb, parser, _config, stdout); let s = "This is the first line\nThis is the second line\nThis is the third line\n"; let s = [ s.as_bytes(), @@ -42,7 +42,7 @@ fn clear_line_from_cursor() { #[test] fn clear_from_cursor_to_top() { - setup!(sb, parser, config, stdout); + setup!(sb, parser, _config, stdout); let s = "This is the first line\n".to_owned() + "This is the second line\n" + "This is the third line\n" @@ -70,7 +70,7 @@ fn clear_from_cursor_to_top() { #[test] fn clear_and_overwrite() { - setup!(sb, parser, config, stdout); + setup!(sb, parser, _config, stdout); let s = "This is the first line\n".to_owned() + "This is the second line\n" + "This is the third line\n" diff --git a/sericom-core/src/screen/tests/escape.rs b/sericom-core/src/screen/tests/escape.rs index 4899ad3..9d83ae4 100644 --- a/sericom-core/src/screen/tests/escape.rs +++ b/sericom-core/src/screen/tests/escape.rs @@ -109,7 +109,7 @@ fn bold_italic_span() { assert_span_eq!(sb, 0, 0, attrs => span_attrs); } -#[test] +#[test_log::test] #[allow(clippy::cognitive_complexity)] fn multiline_multicolor() { setup!(sb, parser, config, stdout); diff --git a/sericom-core/src/screen/tests/mod.rs b/sericom-core/src/screen/tests/mod.rs index 79e2aba..09f2a54 100644 --- a/sericom-core/src/screen/tests/mod.rs +++ b/sericom-core/src/screen/tests/mod.rs @@ -1,7 +1,7 @@ mod colors; +mod components; mod cursor; mod escape; -mod line; pub use crate::{ configs::{ConfigOverride, initialize_config}, diff --git a/sericom-core/src/screen/ui_command.rs b/sericom-core/src/screen/ui_command.rs index ec054f6..154c990 100644 --- a/sericom-core/src/screen/ui_command.rs +++ b/sericom-core/src/screen/ui_command.rs @@ -120,16 +120,14 @@ impl UIAction for ScreenBuffer { self.lines.clear(); self.view_start = 0; self.set_cursor_pos((0_u16, 0_usize)); - self.lines - .push_back(Line::reserve_new(usize::from(self.width()))); + self.lines.push_back(Line::new_empty(self.width().into())); // self.needs_render = true; } /// Clears the current *visible* screen while keeping the buffer's history fn clear_screen(&mut self) { for _ in 0..self.rect.height { - self.lines - .push_back(Line::reserve_new(usize::from(self.width()))); + self.lines.push_back(Line::new_empty(self.width().into())); } self.view_start = self.lines.len().saturating_sub(self.rect.height as usize); // self.needs_render = true; diff --git a/sericom-core/src/ui/buffer.rs b/sericom-core/src/ui/buffer.rs index 49c95e7..ee511ca 100644 --- a/sericom-core/src/ui/buffer.rs +++ b/sericom-core/src/ui/buffer.rs @@ -20,7 +20,7 @@ impl Buffer { #[must_use] pub fn filled(area: Rect, span: Span) -> Self { // let line = Line::new(area.width.into(), span); - let line = Line::reserve_new(area.width.into()); + let line = Line::new_empty(area.width.into()); let size = area.height as usize; let content = vec![line; size]; Self { area, content } From c10a3cba034ce54b90ae1247c411629e507697e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:23:54 +0000 Subject: [PATCH 17/40] chore(deps): bump tracing from 0.1.41 to 0.1.43 Bumps [tracing](https://github.com/tokio-rs/tracing) from 0.1.41 to 0.1.43. - [Release notes](https://github.com/tokio-rs/tracing/releases) - [Commits](https://github.com/tokio-rs/tracing/compare/tracing-0.1.41...tracing-0.1.43) --- updated-dependencies: - dependency-name: tracing dependency-version: 0.1.43 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ee7187..08e4f27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1006,9 +1006,9 @@ checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -1029,9 +1029,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -1040,9 +1040,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", diff --git a/Cargo.toml b/Cargo.toml index 4b07084..ea60728 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ crossterm = { version = "0.29.0", features = ["osc52", "event-stream"] } miette = { version = "7.6.0", features = ["fancy"] } serial2-tokio = { version = "0.1.19", features = ["windows"] } tokio = { version = "1.48.0", features = ["full"] } -tracing = "0.1.41" +tracing = "0.1.43" [patch.crates-io] sericom-core = { path = "sericom-core" } From f3ef98ec97405ed1b030f589505cdfa2c61102a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:24:02 +0000 Subject: [PATCH 18/40] chore(deps): bump tracing-subscriber from 0.3.20 to 0.3.22 Bumps [tracing-subscriber](https://github.com/tokio-rs/tracing) from 0.3.20 to 0.3.22. - [Release notes](https://github.com/tokio-rs/tracing/releases) - [Commits](https://github.com/tokio-rs/tracing/compare/tracing-subscriber-0.3.20...tracing-subscriber-0.3.22) --- updated-dependencies: - dependency-name: tracing-subscriber dependency-version: 0.3.22 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 8 ++++---- sericom/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ee7187..49b47e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1040,9 +1040,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -1061,9 +1061,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "nu-ansi-term", "sharded-slab", diff --git a/sericom/Cargo.toml b/sericom/Cargo.toml index 224301f..58d6d4f 100644 --- a/sericom/Cargo.toml +++ b/sericom/Cargo.toml @@ -31,7 +31,7 @@ rustflags = ["-C", "target-feature=+crt-static"] clap = { version = "4.5.50", features = ["derive"] } sericom-core = { version = "0.7.0", path = "../sericom-core" } tracing-appender = "0.2" -tracing-subscriber = "0.3.20" +tracing-subscriber = "0.3.22" chrono.workspace = true tokio.workspace = true serial2-tokio.workspace = true From 623aee44ae44a1f6cab1faf864c428b3b727e2f1 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Sat, 6 Dec 2025 13:16:14 -0600 Subject: [PATCH 19/40] refactor(fmt): Formatting Committing so that I can open a new branch for the REPL CLI feature for sericom changelog: ignore From 6d3a56646919ab5443aa34d526248029d52d5dde Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Sat, 6 Dec 2025 13:17:48 -0600 Subject: [PATCH 20/40] feat(repl): Add REPL functionality for sericom --- Cargo.lock | 113 +++++++++++++++++++++++ justfile | 2 +- sericom/Cargo.toml | 1 + sericom/src/main.rs | 219 ++++++++++++++++++++++++++++++-------------- 4 files changed, 263 insertions(+), 72 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 455d2b9..4732370 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -208,6 +214,15 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -312,6 +327,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "env_filter" version = "0.1.4" @@ -349,6 +370,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.0" @@ -379,6 +417,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.1", +] + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -541,6 +588,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -645,6 +713,16 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "redox_syscall" version = "0.5.17" @@ -696,6 +774,40 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "rustyline-derive", + "unicode-segmentation", + "unicode-width 0.2.1", + "utf8parse", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustyline-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -772,6 +884,7 @@ dependencies = [ "clap", "crossterm", "miette", + "rustyline", "serial2-tokio", "sericom-core", "tokio", diff --git a/justfile b/justfile index 44a741e..b25e4e0 100644 --- a/justfile +++ b/justfile @@ -32,7 +32,7 @@ list-tests: cargo test -- --list run: - cargo run -- /dev/ttyUSB0 + cargo run -p sericom run-file: cargo run -- /dev/ttyUSB0 -f diff --git a/sericom/Cargo.toml b/sericom/Cargo.toml index 224301f..931ae68 100644 --- a/sericom/Cargo.toml +++ b/sericom/Cargo.toml @@ -29,6 +29,7 @@ rustflags = ["-C", "target-feature=+crt-static"] [dependencies] clap = { version = "4.5.50", features = ["derive"] } +rustyline = { version = "17.0.1", features = ["derive"] } sericom-core = { version = "0.7.0", path = "../sericom-core" } tracing-appender = "0.2" tracing-subscriber = "0.3.20" diff --git a/sericom/src/main.rs b/sericom/src/main.rs index e39003e..9673f11 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -24,25 +24,15 @@ use std::{ path::{Path, PathBuf}, }; +const RED: &str = "\x1b\x5b31m"; +const BOLD: &str = "\x1b\x5b1m"; +const UNBOLD: &str = "\x1b\x5b22m"; +const RESET: &str = "\x1b\x5b0m"; + #[derive(Parser)] #[command(name = "sericom", version, about, long_about = None)] #[command(propagate_version = true)] struct Cli { - /// The path to a serial port. - /// - /// For Linux/MacOS something like `/dev/tty1`, Windows `COM1`. - port: Option, - /// Baud rate for the serial connection. - #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] - baud: u32, - #[clap(flatten)] - config_override: ConfigOverrides, - /// Path to a file for the output. - #[arg(short, long)] - file: Option>, - /// Display debug output - #[arg(short, long)] - debug: bool, #[command(subcommand)] command: Option, } @@ -50,6 +40,25 @@ struct Cli { #[allow(clippy::enum_variant_names)] #[derive(Subcommand)] enum Commands { + /// Connect to a serial port + #[command(alias = "c")] + Connect { + /// The path to a serial port. + /// + /// For Linux/MacOS something like `/dev/ttyUSB0`, Windows `COM1`. + port: String, + /// Baud rate for the serial connection. + #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] + baud: u32, + #[clap(flatten)] + config_override: ConfigOverrides, + /// Path to a file for the output. + #[arg(short, long)] + file: Option>, + /// Display debug output + #[arg(short, long)] + debug: bool, + }, /// Lists valid baud rates Bauds, /// Lists all available serial ports @@ -89,75 +98,143 @@ impl From for sericom_core::configs::ConfigOverride { } } +async fn run_repl() -> miette::Result<()> { + let conf = rustyline::Config::builder() + .history_ignore_space(true) + .build(); + let mut rl = rustyline::DefaultEditor::with_config(conf).expect("Failed to create REPL"); + let history_path = std::env::home_dir() + .unwrap_or(PathBuf::from("./")) + .join(".repl_history"); + + if rl.load_history(&history_path).is_err() { + println!("No previous history"); + } + + println!("Welcome to the sericom, type 'exit' to quit."); + + loop { + let readline = rl.readline(">> "); + match readline { + Ok(line) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + + rl.add_history_entry(line).ok(); + + if line == "exit" { + break; + } + + if line.contains('?') { + handle_help(line); + continue; + } + + let args = format!("sericom {}", line); + let cli = Cli::try_parse_from(args.split_whitespace()); + match cli { + Ok(cli) => { + if let Some(cmd) = cli.command { + handle_cmds(cmd).await? + } + } + Err(e) => eprintln!("{e}"), + } + } + Err(_) => break, + } + } + + rl.save_history(&history_path).ok(); + Ok(()) +} + +fn handle_help(line: &str) { + let mut cmd = Cli::command(); + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.is_empty() || tokens == ["?"] { + cmd.print_help().unwrap(); + println!(); + return; + } + + if let Some((sub, rest)) = tokens.split_first() + && *rest == ["?"] + && let Some(mut sub_cmd) = cmd + .get_subcommands_mut() + .find(|s| s.get_name() == *sub) + .cloned() + { + sub_cmd.print_help().ok(); + println!(); + return; + } + + println!("{RED}No help found for '{BOLD}{line}{UNBOLD}'.{RESET}"); +} + #[tokio::main] async fn main() -> miette::Result<()> { let cli = Cli::parse(); - if cli.port.is_none() && cli.command.is_none() { - let mut cmd = Cli::command(); - cmd.error( - clap::error::ErrorKind::MissingRequiredArgument, - "Missing either PORT or COMMAND.", - ) - .exit(); + if let Some(cmd) = cli.command { + handle_cmds(cmd).await? + } else { + run_repl().await? } + Ok(()) +} - if cli.port.is_some() && cli.command.is_some() { - let mut cmd = Cli::command(); - cmd.error( - clap::error::ErrorKind::ArgumentConflict, - "Must specify either PORT or SUBCOMMAND, not both.", - ) - .exit(); - } +async fn handle_cmds(cmd: Commands) -> miette::Result<()> { + match cmd { + Commands::Connect { + port, + baud, + config_override, + file, + debug, + } => { + let connection = open_connection(baud, &port)?; + let overrides: sericom_core::configs::ConfigOverride = config_override.into(); - if let Some(ref port) = cli.port { - let connection = open_connection(cli.baud, port)?; - let overrides: sericom_core::configs::ConfigOverride = cli.config_override.into(); - - if let Some(Some(path)) = &cli.file - && path.is_dir() - { - return Err(miette::miette!( - "Could not create file at: '{}' because it is a directory.", - path.display() - )); + if let Some(Some(path)) = &file + && path.is_dir() + { + return Err(miette::miette!( + "Could not create file at: '{}' because it is a directory.", + path.display() + )); + } + initialize_config(overrides)?; + // Need to hold the guard in `main`'s scope + let _guard: Option = if debug { + let config = get_config(); + let out_dir = config.defaults.debug_dir.as_path(); + init_tracing(out_dir, &port)? + } else { + None + }; + interactive_session(connection, file, debug, &port).await?; + Ok(()) } - initialize_config(overrides)?; - // Need to hold the guard in `main`'s scope - let _guard: Option = if let Some(ref port) = - cli.port - && cli.debug - { - let config = get_config(); - let out_dir = config.defaults.debug_dir.as_path(); - init_tracing(out_dir, port)? - } else { - None - }; - interactive_session(connection, cli.file, cli.debug, port).await?; - } else if let Some(cmd) = cli.command { - match cmd { - Commands::Bauds => { - let mut stdout = io::stdout(); - write!(stdout, "Valid baud rates:\r\n") + Commands::Bauds => { + let mut stdout = io::stdout(); + write!(stdout, "Valid baud rates:\r\n") + .into_diagnostic() + .wrap_err("Failed to write to stdout.".red())?; + for baud in serial2_tokio::COMMON_BAUD_RATES { + write!(stdout, "{baud}\r\n") .into_diagnostic() .wrap_err("Failed to write to stdout.".red())?; - for baud in serial2_tokio::COMMON_BAUD_RATES { - write!(stdout, "{baud}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - } - } - Commands::Ports => { - list_serial_ports()?; - } - Commands::Settings { baud, port } => { - get_settings(baud, &port)?; } + Ok(()) } + Commands::Ports => list_serial_ports(), + Commands::Settings { baud, port } => get_settings(baud, &port), } - Ok(()) } fn init_tracing( From b29139177de8c27b84be360e83101480c8127b80 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Mon, 8 Dec 2025 21:57:36 -0600 Subject: [PATCH 21/40] refactor(sessions): Working towards multiple session handling changelog: ignore --- sericom-core/src/lib.rs | 4 +- sericom-core/src/screen/driver.rs | 17 +--- sericom-core/src/screen/mod.rs | 1 + sericom-core/src/screen/process/cursor.rs | 11 +-- sericom-core/src/screen/process/screen.rs | 7 +- sericom-core/src/session.rs | 114 ++++++++++++++++++++++ 6 files changed, 125 insertions(+), 29 deletions(-) create mode 100644 sericom-core/src/session.rs diff --git a/sericom-core/src/lib.rs b/sericom-core/src/lib.rs index d1c4b1b..7ff9ec1 100644 --- a/sericom-core/src/lib.rs +++ b/sericom-core/src/lib.rs @@ -11,13 +11,11 @@ #![allow(clippy::missing_errors_doc)] #![allow(clippy::missing_panics_doc)] -#[cfg(all(feature = "cli", feature = "gui"))] -compile_error!("features `cli` and `gui` cannot be enabled simultaneosly"); - pub mod cli; pub mod configs; pub mod debug; pub mod path_utils; pub mod screen; pub mod serial_actor; +pub mod session; pub mod ui; diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/driver.rs index 33135a9..dcde51f 100644 --- a/sericom-core/src/screen/driver.rs +++ b/sericom-core/src/screen/driver.rs @@ -11,25 +11,18 @@ use super::process::{BK, BS, CR, ColorState, ESC, FF, NL, ParserEvent, TAB}; use super::{process_colors, process_cursor, process_erase}; /// The layer between incoming [`ParserEvent`]s and the [`ScreenBuffer`]. -pub struct ScreenDriver<'a, W: std::io::Write> { +pub struct ScreenDriver<'a> { buffer: &'a mut ScreenBuffer, color_state: ColorState, attrs: Attributes, - // TODO: Figure out if this is necessary, implemented Command for Line and - // Span, so the only thing I can think that this would be useful for is - // clearing the screen. - // - // stdout gives access to call crossterm::execute!/queue! - stdout: &'a mut W, } -impl<'a, W: std::io::Write> ScreenDriver<'a, W> { - pub fn new(buffer: &'a mut ScreenBuffer, stdout: &'a mut W) -> Self { +impl<'a> ScreenDriver<'a> { + pub fn new(buffer: &'a mut ScreenBuffer) -> Self { Self { buffer, color_state: ColorState::default(), attrs: Attributes::default(), - stdout, } } @@ -112,9 +105,9 @@ impl<'a, W: std::io::Write> ScreenDriver<'a, W> { }; match seq_type { EscSequenceType::Cursor(kind) => { - process_cursor(seq, kind, self.buffer, self.stdout); + process_cursor(seq, kind, self.buffer); } - EscSequenceType::Erase(kind) => process_erase(seq, kind, self.buffer, self.stdout), + EscSequenceType::Erase(kind) => process_erase(seq, kind, self.buffer), EscSequenceType::Graphics => { process_colors(seq, &mut self.color_state, &mut self.attrs); self.buffer diff --git a/sericom-core/src/screen/mod.rs b/sericom-core/src/screen/mod.rs index 2bc5056..22c8c24 100644 --- a/sericom-core/src/screen/mod.rs +++ b/sericom-core/src/screen/mod.rs @@ -30,6 +30,7 @@ mod ui_command; pub use buffer::ScreenBuffer; pub use components::{Cell, Line, Span}; +pub use driver::ScreenDriver; pub use position::{BuffPos, Cursor, PosType, PosY, Position, Scope, TermPos, TranslatePos}; pub use process::{ByteParser, ColorState, ParseState, ParserEvent}; pub use rect::Rect; diff --git a/sericom-core/src/screen/process/cursor.rs b/sericom-core/src/screen/process/cursor.rs index da483b4..64ed923 100644 --- a/sericom-core/src/screen/process/cursor.rs +++ b/sericom-core/src/screen/process/cursor.rs @@ -14,12 +14,7 @@ fn ascii_digits_to_integer(body: &[u8]) -> Option { ) } -pub fn process_cursor( - seq: &[u8], - kind: u8, - sb: &mut ScreenBuffer, - stdout: &mut W, -) { +pub fn process_cursor(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { let body = &seq[2..seq.len() - 1]; // ESC[{row};{col}H @@ -82,8 +77,8 @@ pub fn process_cursor( sb.set_cursor_col(n); } } - b's' => sb.save_cursor_pos(stdout), // can use crossterm - b'u' => sb.restore_cursor_pos(stdout), // can use crossterm + b's' => todo!(), // sb.save_cursor_pos(stdout), // can use crossterm + b'u' => todo!(), // sb.restore_cursor_pos(stdout), // can use crossterm b'H' => sb.set_cursor_pos(Position::ORIGIN), _ => {} } diff --git a/sericom-core/src/screen/process/screen.rs b/sericom-core/src/screen/process/screen.rs index c96d790..44503c8 100644 --- a/sericom-core/src/screen/process/screen.rs +++ b/sericom-core/src/screen/process/screen.rs @@ -7,12 +7,7 @@ use std::ops::Range; use crate::screen::{Line, Position, ScreenBuffer, TermPos, TranslatePos}; -pub fn process_erase( - seq: &[u8], - kind: u8, - sb: &mut ScreenBuffer, - _stdout: &mut W, -) { +pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { let body = &seq[2..seq.len() - 1]; match (kind, body) { diff --git a/sericom-core/src/session.rs b/sericom-core/src/session.rs new file mode 100644 index 0000000..6cae199 --- /dev/null +++ b/sericom-core/src/session.rs @@ -0,0 +1,114 @@ +#![allow(unused)] + +use std::{ + collections::HashMap, + sync::{Arc, atomic::AtomicBool}, +}; + +use tokio::task::JoinSet; + +use crate::{ + cli::open_connection, + screen::{Rect, ScreenBuffer}, + serial_actor::{SerialActor, SerialEvent, SerialMessage, tasks::run_stdin_input}, +}; + +pub type SessionID = String; + +pub struct SessionManager { + pub(crate) active: Option, + pub(crate) sessions: HashMap, +} + +pub struct SessionHandle { + pub(crate) buffer: Arc>, + /// Channel for communication from outside the session to the session + pub(crate) tx: tokio::sync::mpsc::Sender, + pub(crate) events: tokio::sync::broadcast::Receiver, + /// A sessions async tasks + pub(crate) tasks: JoinSet<()>, + pub(crate) meta: SessionMeta, +} + +pub struct SessionMeta { + pub(crate) baud: u32, +} + +impl SessionManager { + pub fn new() -> Self { + SessionManager { + active: None, + sessions: HashMap::new(), + } + } + + pub fn spawn<'a>(&mut self, port: &'a str, baud: u32) -> SessionID { + match self.sessions.keys().find(|k| *k == port) { + Some(session) => return session.clone(), + None => { + let (new_id, new_session); + new_id = port.to_owned(); + new_session = SessionHandle::new(port, baud); + + self.sessions.insert(new_id.clone(), new_session); + return new_id; + } + } + } +} + +impl SessionHandle { + pub fn new<'a>(port: &'a str, baud: u32) -> Self { + let (tx, rx) = tokio::sync::mpsc::channel::(100); + let (events_tx, _) = tokio::sync::broadcast::channel::(128); + + let (session_events, parser_events); + session_events = events_tx.subscribe(); + parser_events = events_tx.subscribe(); + + let connection = open_connection(baud, port).unwrap(); + let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 20)); + + let sb = ScreenBuffer::new(Rect::new((0u16, 0u16).into(), term_w, term_h)); + let buffer = Arc::new(tokio::sync::RwLock::new(sb)); + let actor = SerialActor::new(connection, rx, events_tx); + + let mut tasks = JoinSet::new(); + tasks.spawn(actor.run()); + tasks.spawn(parse_task(Arc::clone(&buffer), parser_events)); + + Self { + buffer, + tx, + events: session_events, + tasks, + meta: SessionMeta { baud }, + } + } +} + +async fn parse_task( + buffer: Arc>, + mut events: tokio::sync::broadcast::Receiver, +) { + use crate::screen::{ByteParser, ScreenDriver}; + + let mut parser = ByteParser::new(); + + while let Ok(event) = events.recv().await { + match event { + SerialEvent::Data(bytes) => { + let parsed = parser.feed(&bytes); + + // Short-lived write lock + { + let mut buf = buffer.write().await; + let mut driver = ScreenDriver::new(&mut buf); + driver.process_events(parsed); + } + } + SerialEvent::Error(_) => todo!(), + SerialEvent::ConnectionClosed => break, + } + } +} From 43e2de3e10d9f4c2d4e4ab8dc92a8c5f75385db0 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Sat, 6 Dec 2025 13:16:14 -0600 Subject: [PATCH 22/40] refactor(fmt): Formatting Committing so that I can open a new branch for the REPL CLI feature for sericom changelog: ignore (sericom-core) --- sericom-core/Cargo.toml | 7 +----- sericom-core/src/screen/process/screen.rs | 27 ----------------------- 2 files changed, 1 insertion(+), 33 deletions(-) diff --git a/sericom-core/Cargo.toml b/sericom-core/Cargo.toml index f159787..0c19f54 100644 --- a/sericom-core/Cargo.toml +++ b/sericom-core/Cargo.toml @@ -8,17 +8,12 @@ edition.workspace = true exclude.workspace = true repository.workspace = true -[features] -default = ["cli"] -cli = ["dep:crossterm"] -gui = [] - [dependencies] serde = { version = "1.0.228", features = ["derive"] } thiserror = "2.0.17" toml = "0.9.8" chrono.workspace = true -crossterm = { workspace = true, optional = true } +crossterm.workspace = true miette.workspace = true serial2-tokio.workspace = true tokio.workspace = true diff --git a/sericom-core/src/screen/process/screen.rs b/sericom-core/src/screen/process/screen.rs index 44503c8..30f0493 100644 --- a/sericom-core/src/screen/process/screen.rs +++ b/sericom-core/src/screen/process/screen.rs @@ -1,8 +1,3 @@ -// #[cfg(feature = "cli")] -// use crossterm::{ -// execute, -// terminal::{Clear, ClearType}, -// }; use std::ops::Range; use crate::screen::{Line, Position, ScreenBuffer, TermPos, TranslatePos}; @@ -13,8 +8,6 @@ pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { match (kind, body) { // erase from cursor until end of screen (b'J', [] | [b'0']) => { - // #[cfg(feature = "gui")] - // { let buff_range = Range { start: (sb.to_buff(sb.cursor).y + 1) as usize, end: (sb.buff_rect().bottom() + 1) as usize, @@ -22,18 +15,9 @@ pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { sb.clear_line_from_cursor(); sb.clear_lines(buff_range); - // } - // - // #[cfg(feature = "cli")] - // execute!(stdout, Clear(ClearType::FromCursorDown)); } // erase from cursor to beginning of screen (b'J', [b'1']) => { - // #[cfg(feature = "cli")] - // execute!(stdout, Clear(ClearType::FromCursorUp)); - // - // #[cfg(feature = "gui")] - // { let buff_range = Range { start: sb.view_start, end: sb.to_buff(sb.cursor).y as usize, @@ -41,7 +25,6 @@ pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { sb.clear_lines(buff_range); sb.clear_line_to_cursor(); - // } } // erase entire screen - move cursor to ORIGIN // erase saved lines - same as erase entire screen @@ -50,9 +33,6 @@ pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { sb.lines.push_back(Line::new_empty(usize::from(sb.width()))); sb.view_start = sb.lines.len().saturating_sub(1); sb.cursor = Position::::ORIGIN; - - // #[cfg(feature = "cli")] - // execute!(stdout, Clear(ClearType::All)); } // erase from cursor until end of line (b'K', [] | [b'0']) => sb.clear_line_from_cursor(), @@ -60,17 +40,11 @@ pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { (b'K', [b'1']) => sb.clear_line_to_cursor(), // erase the entire line (b'K', [b'2']) => { - // #[cfg(feature = "cli")] - // execute!(stdout, Clear(ClearType::CurrentLine)); - // - // #[cfg(feature = "gui")] - // { sb.with_current_line(|line, _| { line.iter_mut().flatten().for_each(|cell| { cell.character = ' '; }); }); - // } } _ => {} } @@ -98,7 +72,6 @@ impl ScreenBuffer { }); } - // #[cfg(feature = "gui")] pub(crate) fn clear_lines(&mut self, range: Range) { for line in self.lines.range_mut(range) { line.iter_mut().flatten().for_each(|cell| { From d55db449b3a5edc25d7e50765c23d4e5fe1b9cdd Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Sat, 6 Dec 2025 13:32:30 -0600 Subject: [PATCH 23/40] refactor: Removed debug module since sericom-core emits tracing events Debug events should be emitted via tracing events within sericom-core and captured by the consumer i.e. sericom and handled as seen fit. In sericom's case, it will simply use `tracing-appender` to capture the trace events to a file. changelog: ignore --- sericom-core/src/cli.rs | 10 +--- sericom-core/src/debug.rs | 96 --------------------------------------- sericom-core/src/lib.rs | 1 - sericom/src/main.rs | 2 +- 4 files changed, 2 insertions(+), 107 deletions(-) delete mode 100644 sericom-core/src/debug.rs diff --git a/sericom-core/src/cli.rs b/sericom-core/src/cli.rs index 8bdcdcd..7daf998 100644 --- a/sericom-core/src/cli.rs +++ b/sericom-core/src/cli.rs @@ -4,9 +4,7 @@ use crate::{ compat_port_path, configs::get_config, - create_recursive, - debug::run_debug_output, - map_miette, + create_recursive, map_miette, screen::UICommand, serial_actor::{ SerialActor, SerialEvent, SerialMessage, @@ -30,7 +28,6 @@ use tracing::{Level, trace}; pub async fn interactive_session( connection: SerialPort, file_path: Option>, - debug: bool, port_name: &str, ) -> miette::Result<()> { let span = tracing::span!(Level::TRACE, "Interactive Session"); @@ -89,11 +86,6 @@ pub async fn interactive_session( }); } - if debug { - let debug_rx = broadcast_event_tx.subscribe(); - tasks.spawn(run_debug_output(debug_rx)); - } - let actor = SerialActor::new(connection, command_rx, broadcast_event_tx); tasks.spawn(actor.run()); diff --git a/sericom-core/src/debug.rs b/sericom-core/src/debug.rs deleted file mode 100644 index 9a552ad..0000000 --- a/sericom-core/src/debug.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! As of now, there is only one function, [`run_debug_output`], which is meant -//! to debug the data being received over the serial connection. In future -//! updates, this module is intended to be used for running tracing events with -//! the [`tracing`](https://docs.rs/tracing/latest/tracing/) crate. - -use crate::serial_actor::SerialEvent; - -/// This function is used for debugging the data that is sent from a device. -/// -/// It will create a file "debug.txt" and print the data received from the device -/// as the actual bytes received along with the corresponding ascii characters. -/// -/// Data is sent from the [`SerialActor`][crate::serial_actor::SerialActor] to this function in batches, -/// therefore a line written to "debug.txt" may look like this: -/// -/// "\[04:41:27.550\] RX 9 bytes: \[0D, 0A, 53, 77, 69, 74, 63, 68\]... UTF8: ^M Switch#" -/// -/// Each line will only print a maximum of 8 bytes, after 8 it will simply write "...". -pub async fn run_debug_output(mut rx: tokio::sync::broadcast::Receiver) { - use std::io::{BufWriter, Write}; - use std::path::Path; - - let (write_tx, write_rx) = std::sync::mpsc::channel::>(); - let write_handle = tokio::task::spawn_blocking(move || { - let path = Path::new("./debug.txt"); - let file = match std::fs::File::create(path) { - Ok(f) => f, - Err(e) => { - eprintln!("Failed to create file: {e}"); - return; - } - }; - let mut writer = BufWriter::with_capacity(48 * 1024, file); - let mut last_flush = std::time::Instant::now(); - - writeln!(writer, "Session started at: {}", chrono::Utc::now()).ok(); - while let Ok(data) = write_rx.recv() { - // Prints bytes of all characters - writeln!( - writer, - "[{}] RX {} bytes: {:02X?}{} UTF8: {}", - chrono::Utc::now().format("%H:%M:%S%.3f"), - data.len(), - &data[..std::cmp::min(20, data.len())], - if data.len() > 10 { "..." } else { "" }, - String::from_utf8_lossy(&data) - ) - .ok(); - - let now = std::time::Instant::now(); - if now.duration_since(last_flush) > std::time::Duration::from_millis(100) - || writer.buffer().len() > 32 * 1024 - { - let _ = writer.flush(); - last_flush = now; - } - } - let _ = writer.flush(); - }); - - let data_streamer = tokio::spawn(async move { - let mut write_buf = Vec::with_capacity(4096); - let mut batch_timer = tokio::time::interval(tokio::time::Duration::from_millis(200)); - - loop { - tokio::select! { - event = rx.recv() => { - match event { - Ok(SerialEvent::Data(data)) => { - write_buf.extend_from_slice(&data); - if write_buf.len() >= 4096 && write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - eprintln!("File writer lagged, skipped {skipped} messages"); - } - _ => break, - } - } - _ = batch_timer.tick() => { - if !write_buf.is_empty() && write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - } - } - } - if !write_buf.is_empty() { - let _ = write_tx.send(std::mem::take(&mut write_buf)); - } - drop(write_tx); - }); - - let _ = data_streamer.await; - let _ = write_handle.await; -} diff --git a/sericom-core/src/lib.rs b/sericom-core/src/lib.rs index 7ff9ec1..cc17825 100644 --- a/sericom-core/src/lib.rs +++ b/sericom-core/src/lib.rs @@ -13,7 +13,6 @@ pub mod cli; pub mod configs; -pub mod debug; pub mod path_utils; pub mod screen; pub mod serial_actor; diff --git a/sericom/src/main.rs b/sericom/src/main.rs index 9673f11..937c213 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -217,7 +217,7 @@ async fn handle_cmds(cmd: Commands) -> miette::Result<()> { } else { None }; - interactive_session(connection, file, debug, &port).await?; + interactive_session(connection, file, &port).await?; Ok(()) } Commands::Bauds => { From ccf50add08464f9beb551b14928bb807b9a60d50 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Thu, 11 Dec 2025 21:13:28 -0600 Subject: [PATCH 24/40] refactor(rendering): Change `Cell::character` to `u8` for memory opt Decided to change `Cell::character` from `char` to `u8` because `Cell`s are only dynamically constructed from the `ByteParser` and all text is verified to be ascii. This means that it would be no problem to store as `u8` and simply cast the `u8` to a char when printing/writing if needed. Switching from `char` to `u8` reduces the size and alignment of `Cell`, and since there will be _loads_ of `Cell`s in memory, I thought this was a valid change. When using `char` the size of `Cell` is 8 and alignment is `4`, when using `u8`, the size is `2` and alignment `1`. --- sericom-core/src/screen/components/cell.rs | 51 ++++++++++++++++----- sericom-core/src/screen/components/line.rs | 2 +- sericom-core/src/screen/components/span.rs | 4 +- sericom-core/src/screen/driver.rs | 5 +- sericom-core/src/screen/process/screen.rs | 8 ++-- sericom-core/src/screen/tests/components.rs | 20 +++++--- sericom-core/src/screen/tests/cursor.rs | 6 +-- sericom-core/src/screen/tests/escape.rs | 14 +++--- sericom-core/src/screen/tests/mod.rs | 2 +- 9 files changed, 73 insertions(+), 39 deletions(-) diff --git a/sericom-core/src/screen/components/cell.rs b/sericom-core/src/screen/components/cell.rs index a43a296..0e75b0b 100644 --- a/sericom-core/src/screen/components/cell.rs +++ b/sericom-core/src/screen/components/cell.rs @@ -6,18 +6,24 @@ use std::ops::{Deref, DerefMut}; /// Each line within [`ScreenBuffer`][`super::ScreenBuffer`] is represented by a `Vec`. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Cell { - pub(crate) character: char, + /// Stored as u8 for memory size optimization (1 byte vs 4) + /// + /// [`Cell`] is only constructed from valid ascii characters from the [`ByteParser`] + /// and since it is only ascii characters, it is safe to store as [`u8`] and + /// later cast as char. This reduces the size of [`Cell`] from 8 to 2 (if [`char`] + /// were used instead of [`u8`]). + pub(crate) character: u8, pub(crate) is_selected: bool, } impl Cell { - pub const CARRIGE: Self = Self::new('\r'); - pub const EMPTY: Self = Self::new(' '); - pub const NEWLINE: Self = Self::new('\n'); - pub const TAB: Self = Self::new('\t'); + pub const CARRIGE: Self = Self::new(b'\r'); + pub const EMPTY: Self = Self::new(b' '); + pub const NEWLINE: Self = Self::new(b'\n'); + pub const TAB: Self = Self::new(b'\t'); #[must_use] - pub const fn new(character: char) -> Self { + pub const fn new(character: u8) -> Self { Self { character, is_selected: false, @@ -26,7 +32,7 @@ impl Cell { } impl Deref for Cell { - type Target = char; + type Target = u8; fn deref(&self) -> &Self::Target { &self.character @@ -40,29 +46,48 @@ impl DerefMut for Cell { } impl From for Cell { + /// WARNING: This will have unexpected behavior if the `char` is _NOT_ valid ASCII fn from(value: char) -> Self { - Self::new(value) + Self::new(value as u8) } } impl From<&char> for Cell { + /// WARNING: This will have unexpected behavior if the `char` is _NOT_ valid ASCII fn from(value: &char) -> Self { + Self::new(*value as u8) + } +} + +impl From for Cell { + fn from(value: u8) -> Self { + Self::new(value) + } +} + +impl From<&u8> for Cell { + fn from(value: &u8) -> Self { Self::new(*value) } } impl From for char { + fn from(value: Cell) -> Self { + value.character as Self + } +} + +impl From for u8 { fn from(value: Cell) -> Self { value.character } } impl Default for Cell { - /// The default for [`Cell`] is the fg color from [`Appearance.fg`][`crate::configs::Appearance`], - /// the bg color from [`Appearance.bg`][`crate::configs::Appearance`], `' '` for the character, and is not selected. + /// Default is a [space] char (' ') and is_selected = false fn default() -> Self { Self { - character: ' ', + character: b' ', is_selected: false, } } @@ -70,6 +95,8 @@ impl Default for Cell { impl<'a> FromIterator<&'a Cell> for std::string::String { fn from_iter>(iter: T) -> Self { - iter.into_iter().map(Deref::deref).collect::() + iter.into_iter() + .map(|c| c.character as char) + .collect::() } } diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index 370d62b..b47b3a6 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -89,7 +89,7 @@ impl Line { .iter() .flatten() .rev() - .position(|c| c.character != ' ') + .position(|c| c.character != b' ') .unwrap_or(0) } diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index ef0cbfa..62a95df 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -47,7 +47,7 @@ impl Span { - self .iter() .rev() - .position(|c| c.character != ' ') + .position(|c| c.character != b' ') .unwrap_or(0) } @@ -251,7 +251,7 @@ impl std::fmt::Debug for Span { self.cells.len(), self.cells.capacity() ); - s.extend(self.cells.iter().map(Deref::deref)); + s.extend(self.cells.iter().map(|c| c.character as char)); s.push(')'); f.write_str(&s) } diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/driver.rs index dcde51f..52e7af0 100644 --- a/sericom-core/src/screen/driver.rs +++ b/sericom-core/src/screen/driver.rs @@ -40,8 +40,7 @@ impl<'a> ScreenDriver<'a> { fn write_text(&mut self, bytes: &[u8]) { self.buffer.with_current_span(|span, offset| { for (cell, ch) in span.cells.iter_mut().skip(offset).zip(bytes) { - // can cast ch as char because the parser will only pass utf-8 - cell.character = *ch as char; + cell.character = *ch; } }); @@ -64,7 +63,7 @@ impl<'a> ScreenDriver<'a> { span.cells .get_mut(last) .expect("span len is greater than last filled cell") - .character = '\n'; + .character = b'\n'; } tracing::trace!(target: "parser::newline", ?line); }); diff --git a/sericom-core/src/screen/process/screen.rs b/sericom-core/src/screen/process/screen.rs index 30f0493..bbc2e0a 100644 --- a/sericom-core/src/screen/process/screen.rs +++ b/sericom-core/src/screen/process/screen.rs @@ -42,7 +42,7 @@ pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { (b'K', [b'2']) => { sb.with_current_line(|line, _| { line.iter_mut().flatten().for_each(|cell| { - cell.character = ' '; + cell.character = b' '; }); }); } @@ -55,7 +55,7 @@ impl ScreenBuffer { self.with_current_line(|line, cursor| { for (idx, cell) in line.iter_mut().flatten().enumerate() { if idx < usize::from(cursor.x) { - cell.character = ' '; + cell.character = b' '; } } }); @@ -67,7 +67,7 @@ impl ScreenBuffer { .flatten() .skip(usize::from(cursor.x)) .for_each(|cell| { - cell.character = ' '; + cell.character = b' '; }); }); } @@ -75,7 +75,7 @@ impl ScreenBuffer { pub(crate) fn clear_lines(&mut self, range: Range) { for line in self.lines.range_mut(range) { line.iter_mut().flatten().for_each(|cell| { - cell.character = ' '; + cell.character = b' '; }); } } diff --git a/sericom-core/src/screen/tests/components.rs b/sericom-core/src/screen/tests/components.rs index e454129..03ddd87 100644 --- a/sericom-core/src/screen/tests/components.rs +++ b/sericom-core/src/screen/tests/components.rs @@ -53,12 +53,20 @@ fn line_as_command() { execute!(writer, line); let mut cmp = String::new(); - write!(cmp, "{}", SetColors(Colors::new(Color::Cyan, Color::Reset))); + write!( + cmp, + "{}", + SetColors(Colors::new(Color::Green, Color::Reset)) + ); write!(cmp, "first span"); write!(cmp, "{}", SetColors(Colors::new(Color::Red, Color::Reset))); write!(cmp, "{}", SetAttribute(Attribute::Bold)); write!(cmp, "second span"); - write!(cmp, "{}", SetColors(Colors::new(Color::Cyan, Color::Reset))); + write!( + cmp, + "{}", + SetColors(Colors::new(Color::Green, Color::Reset)) + ); writeln!(cmp, "third span"); assert_eq!(writer.buffer, cmp); @@ -67,13 +75,13 @@ fn line_as_command() { #[test] fn overwriting_span() { let mut span = vec![Cell::EMPTY; 10]; - let chars = vec!['a'; 10]; + let chars = vec![b'a'; 10]; for (cell, ch) in span.iter_mut().skip(5).zip(chars) { cell.character = ch; } - assert_eq!(span, [[Cell::EMPTY; 5], [Cell::new('a'); 5]].concat()); + assert_eq!(span, [[Cell::EMPTY; 5], [Cell::new(b'a'); 5]].concat()); } #[test] @@ -88,7 +96,7 @@ fn span_newline() { .cells .get_mut(res) .expect("span len is greater than last filled cell"); - c.character = '\n'; + c.character = b'\n'; } let res = span.last_filled_idx(); @@ -109,7 +117,7 @@ fn line_last_filled() { let line = Line(vec![span1, span2]); assert_eq!(line.last_filled_idx(), 20); - assert_eq!(line.iter().flatten().nth(20), Some(&Cell::new('n'))); + assert_eq!(line.iter().flatten().nth(20), Some(&Cell::new(b'n'))); } #[test] diff --git a/sericom-core/src/screen/tests/cursor.rs b/sericom-core/src/screen/tests/cursor.rs index 6fd67e5..9f6f597 100644 --- a/sericom-core/src/screen/tests/cursor.rs +++ b/sericom-core/src/screen/tests/cursor.rs @@ -32,7 +32,7 @@ fn clear_line_from_cursor() { .concat(); let parsed = parser.feed(&s); - let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); assert_eq!(sb.lines.len(), 4); @@ -56,7 +56,7 @@ fn clear_from_cursor_to_top() { .concat(); let parsed = parser.feed(&s); - let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); assert_eq!(sb.lines.len(), 6); @@ -85,7 +85,7 @@ fn clear_and_overwrite() { .concat(); let parsed = parser.feed(&s); - let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); assert_eq!(sb.lines.len(), 6); diff --git a/sericom-core/src/screen/tests/escape.rs b/sericom-core/src/screen/tests/escape.rs index 9d83ae4..d6f8e0d 100644 --- a/sericom-core/src/screen/tests/escape.rs +++ b/sericom-core/src/screen/tests/escape.rs @@ -5,7 +5,7 @@ use crate::{assert_line_eq, assert_span_eq, setup}; fn single_plain_line() { setup!(sb, parser, config, stdout); let parsed = parser.feed(b"Hello, world!\n"); - let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -22,7 +22,7 @@ fn single_plain_line() { fn two_lines_plain_text() { setup!(sb, parser, config, stdout); let parsed = parser.feed(b"Hello\r\nWorld\r\n"); - let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -40,7 +40,7 @@ fn two_lines_plain_text() { fn three_color_spans() { setup!(sb, parser, config, stdout); let parsed = parser.feed(b"\x1b[31mRed\x1b[32mGreen\x1b[34mBlue\n"); - let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let bg = Color::from(&config.appearance.bg); @@ -64,7 +64,7 @@ fn three_color_spans() { fn no_newline_incomplete_line() { setup!(sb, parser, config, stdout); let parsed = parser.feed(b"Hello"); - let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -79,7 +79,7 @@ fn no_newline_incomplete_line() { fn mixed_plain_and_color() { setup!(sb, parser, config, stdout); let parsed = parser.feed(b"Normal \x1b[31mRed\n"); - let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -102,7 +102,7 @@ fn bold_italic_span() { setup!(sb, parser, stdout); let parsed = parser.feed(b"\x1b[1;3mHello\n"); // bold + italic - let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let span_attrs = Attributes::from(Attribute::Bold) | Attributes::from(Attribute::Italic); @@ -135,7 +135,7 @@ fn multiline_multicolor() { .as_bytes(); let parsed = parser.feed(input); - let mut driver = ScreenDriver::new(&mut sb, &mut stdout); + let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); // Buffer should have initial empty + 3 lines diff --git a/sericom-core/src/screen/tests/mod.rs b/sericom-core/src/screen/tests/mod.rs index 09f2a54..81e68d8 100644 --- a/sericom-core/src/screen/tests/mod.rs +++ b/sericom-core/src/screen/tests/mod.rs @@ -149,7 +149,7 @@ pub fn debug_dump(lines: &VecDeque) -> String { writeln!(&mut out, "Line {i}:").unwrap(); for (j, span) in line.iter().enumerate() { - let text: String = span.iter().map(|c| c.character).collect(); + let text: String = span.iter().map(|c| c.character as char).collect(); writeln!( &mut out, " Span {}: \"{}\" (len = {}, attrs = {:?}, colors = {:?})", From 3aa1dd268cb3b89ff119bdcab9a8de99f3fe9446 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Thu, 11 Dec 2025 20:41:00 -0600 Subject: [PATCH 25/40] refactor(rendering): Changed ScreenBuffer usize fields to u32 for mem Changed usize fields to u32 to half field size on 64 bit systems since there are guards/logic in place where the values really should never be > usize::MAX --- sericom-core/src/configs/mod.rs | 2 +- sericom-core/src/screen/buffer.rs | 10 ++++---- sericom-core/src/screen/position.rs | 3 +-- sericom-core/src/screen/process/screen.rs | 7 ++++-- sericom-core/src/screen/tests/components.rs | 12 ++------- sericom-core/src/screen/ui_command.rs | 28 +++++++++++++-------- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/sericom-core/src/configs/mod.rs b/sericom-core/src/configs/mod.rs index 3300285..9f785b7 100644 --- a/sericom-core/src/configs/mod.rs +++ b/sericom-core/src/configs/mod.rs @@ -26,7 +26,7 @@ pub static CONFIG: OnceLock = OnceLock::new(); /// Represents the entire `config.toml` configuration file. /// /// See [`Appearance`] and [`Defaults`] -#[derive(Default, Debug, Deserialize, PartialEq)] +#[derive(Default, Debug, Deserialize, PartialEq, Eq)] pub struct Config { #[serde(default)] pub appearance: Appearance, diff --git a/sericom-core/src/screen/buffer.rs b/sericom-core/src/screen/buffer.rs index 7efb940..52b4de7 100644 --- a/sericom-core/src/screen/buffer.rs +++ b/sericom-core/src/screen/buffer.rs @@ -7,7 +7,7 @@ use super::position::{Position, TermPos, TranslatePos}; use super::{Line, Rect}; /// The maximum number of lines stored in memory in [`ScreenBuffer`]. -pub const MAX_SCROLLBACK: usize = 10000; +pub const MAX_SCROLLBACK: u32 = 10000; /// The `ScreenBuffer` holds rendering state for the entire terminal's window/frame. /// @@ -20,17 +20,17 @@ pub struct ScreenBuffer { pub(crate) lines: VecDeque, /// Current view into the buffer. /// Denotes which line is at the top of the screen. - pub(crate) view_start: usize, + pub(crate) view_start: u32, /// The terminal's dimensions pub(crate) rect: Rect, /// Position of the cursor within the `ScreenBuffer`. pub(crate) cursor: Position, /// Start of text selection. Used for highlighting and copying to clipboard. - selection_start: Option<(u16, usize)>, + selection_start: Option<(u16, u32)>, /// End of text selection. Used for highlighting and copying to clipboard. - selection_end: Option<(u16, usize)>, + selection_end: Option<(u16, u32)>, /// Configuration for the maximum amount of lines to keep in memory. - max_scrollback: usize, + max_scrollback: u32, } impl ScreenBuffer { diff --git a/sericom-core/src/screen/position.rs b/sericom-core/src/screen/position.rs index 10261f0..3573a3b 100644 --- a/sericom-core/src/screen/position.rs +++ b/sericom-core/src/screen/position.rs @@ -267,8 +267,7 @@ impl TranslatePos for ScreenBuffer { } fn to_buff(&self, pos: Position) -> Position { - let buff_y = u32::try_from(self.view_start).expect("ScreenBuffer is less than usize::MAX") - + u32::from(pos.y); + let buff_y: u32 = self.view_start + u32::from(pos.y); let buff_x = pos.x.clamp(0, self.width()); Position::::from((buff_x, buff_y)) diff --git a/sericom-core/src/screen/process/screen.rs b/sericom-core/src/screen/process/screen.rs index bbc2e0a..34d1262 100644 --- a/sericom-core/src/screen/process/screen.rs +++ b/sericom-core/src/screen/process/screen.rs @@ -2,6 +2,7 @@ use std::ops::Range; use crate::screen::{Line, Position, ScreenBuffer, TermPos, TranslatePos}; +#[expect(clippy::cast_possible_truncation)] pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { let body = &seq[2..seq.len() - 1]; @@ -19,7 +20,7 @@ pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { // erase from cursor to beginning of screen (b'J', [b'1']) => { let buff_range = Range { - start: sb.view_start, + start: sb.view_start as usize, end: sb.to_buff(sb.cursor).y as usize, }; @@ -31,7 +32,9 @@ pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { // to preserve user scrollback history (b'J', [b'2' | b'3']) => { sb.lines.push_back(Line::new_empty(usize::from(sb.width()))); - sb.view_start = sb.lines.len().saturating_sub(1); + // Casting to u32 from usize is fine because sb.lines should never + // exceed MAX_SCROLLBACK which is < usize::MAX + sb.view_start = (sb.lines.len() as u32).saturating_sub(1); sb.cursor = Position::::ORIGIN; } // erase from cursor until end of line diff --git a/sericom-core/src/screen/tests/components.rs b/sericom-core/src/screen/tests/components.rs index 03ddd87..73a1bb6 100644 --- a/sericom-core/src/screen/tests/components.rs +++ b/sericom-core/src/screen/tests/components.rs @@ -53,20 +53,12 @@ fn line_as_command() { execute!(writer, line); let mut cmp = String::new(); - write!( - cmp, - "{}", - SetColors(Colors::new(Color::Green, Color::Reset)) - ); + write!(cmp, "{}", SetColors(Colors::new(Color::Cyan, Color::Reset))); write!(cmp, "first span"); write!(cmp, "{}", SetColors(Colors::new(Color::Red, Color::Reset))); write!(cmp, "{}", SetAttribute(Attribute::Bold)); write!(cmp, "second span"); - write!( - cmp, - "{}", - SetColors(Colors::new(Color::Green, Color::Reset)) - ); + write!(cmp, "{}", SetColors(Colors::new(Color::Cyan, Color::Reset))); writeln!(cmp, "third span"); assert_eq!(writer.buffer, cmp); diff --git a/sericom-core/src/screen/ui_command.rs b/sericom-core/src/screen/ui_command.rs index 154c990..1d7efed 100644 --- a/sericom-core/src/screen/ui_command.rs +++ b/sericom-core/src/screen/ui_command.rs @@ -6,9 +6,9 @@ use super::{Line, ScreenBuffer}; #[derive(Clone, Debug)] pub enum UICommand { /// Scrolls up by `usize` lines - ScrollUp(usize), + ScrollUp(u32), /// Scrolls down by `usize` lines - ScrollDown(usize), + ScrollDown(u32), /// Scrolls to the last line (most recent) ScrollBottom, /// Scrolls to the beginning of the scrollback buffer (oldest line) @@ -24,8 +24,8 @@ pub enum UICommand { } pub trait UIAction { - fn scroll_up(&mut self, lines: usize); - fn scroll_down(&mut self, lines: usize); + fn scroll_up(&mut self, lines: u32); + fn scroll_down(&mut self, lines: u32); fn scroll_to_bottom(&mut self); fn scroll_to_top(&mut self); fn start_selection(&mut self, pos: Position); @@ -38,7 +38,7 @@ pub trait UIAction { impl UIAction for ScreenBuffer { /// Called to scroll the terminal up by `lines`. - fn scroll_up(&mut self, lines: usize) { + fn scroll_up(&mut self, lines: u32) { if self.view_start >= lines { self.view_start -= lines; } else { @@ -49,8 +49,11 @@ impl UIAction for ScreenBuffer { } /// Called to scroll the terminal down by `lines`. - fn scroll_down(&mut self, lines: usize) { - let max_view_start = self.lines.len().saturating_sub(self.rect.height as usize); + fn scroll_down(&mut self, lines: u32) { + // Cast is fine cause MAX_SCROLLBACK is u32 so lines can't be > u32::MAX + let max_view_start: u32 = (self.lines.len() as u32) + .saturating_sub(self.rect.height.into()) + .into(); self.view_start = (self.view_start + lines).min(max_view_start); self.clear_selection(); // self.needs_render = true; @@ -59,7 +62,8 @@ impl UIAction for ScreenBuffer { /// Scrolls to the bottom of the screen. The bottom of the screen is /// the same as the most recent lines received from the serial connection fn scroll_to_bottom(&mut self) { - self.view_start = self.lines.len().saturating_sub(self.rect.height as usize); + // Cast is fine cause MAX_SCROLLBACK is u32 so lines can't be > u32::MAX + self.view_start = (self.lines.len() as u32).saturating_sub(self.rect.height.into()); // self.needs_render = true; } @@ -73,7 +77,8 @@ impl UIAction for ScreenBuffer { /// Where `screen_x` is the x-position of the start of the selection, /// and `screen_y` is the y-position (line) of the start of the selection. fn start_selection(&mut self, pos: Position) { - let absolute_line = self.view_start + usize::from(pos.y); + use super::TranslatePos; + self.to_buff(pos); self.clear_selection(); // self.selection_start = Some((pos.x, absolute_line)); // self.needs_render = true; @@ -82,7 +87,8 @@ impl UIAction for ScreenBuffer { /// Update's a selection to include the position passed to it. /// Where `screen_x` is the x-position and `screen_y` is the y-position (line). fn update_selection(&mut self, pos: Position) { - let absolute_line = self.view_start + usize::from(pos.y); + use super::TranslatePos; + self.to_buff(pos); // self.selection_end = Some((pos.x, absolute_line)); self.update_selection_highlighting(); // self.needs_render = true; @@ -129,7 +135,7 @@ impl UIAction for ScreenBuffer { for _ in 0..self.rect.height { self.lines.push_back(Line::new_empty(self.width().into())); } - self.view_start = self.lines.len().saturating_sub(self.rect.height as usize); + self.view_start = (self.lines.len() as u32).saturating_sub(self.rect.height.into()); // self.needs_render = true; } } From 7389b4b9ae311249173b3cfdfab95bb6f09a1b9e Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Fri, 12 Dec 2025 00:28:14 -0600 Subject: [PATCH 26/40] refactor(sessions): Basic SessionManager/SessionHandle foundation Implemented the basic foundation/structure for the SessionManager. I am pretty satisfied with the data structure of SessionManager and implemented the basic methods for managing sessions from the SessionManager. changelog: ignore --- justfile | 11 +- sericom-core/src/screen/components/line.rs | 146 -------------------- sericom-core/src/serial_actor/mod.rs | 18 ++- sericom-core/src/session.rs | 114 ---------------- sericom-core/src/session/handle.rs | 141 +++++++++++++++++++ sericom-core/src/session/mod.rs | 152 +++++++++++++++++++++ 6 files changed, 315 insertions(+), 267 deletions(-) delete mode 100644 sericom-core/src/session.rs create mode 100644 sericom-core/src/session/handle.rs create mode 100644 sericom-core/src/session/mod.rs diff --git a/justfile b/justfile index b25e4e0..d1c4483 100644 --- a/justfile +++ b/justfile @@ -1,9 +1,11 @@ -alias l := lint +alias b := build +alias br := build-release alias c := check +alias f := fmt +alias l := lint +alias r := run alias t := test alias tt := test-trace -alias b := build -alias br := build-release default-trace := 'sericom-core' default-tests := '' @@ -25,6 +27,9 @@ check-win: clean: cargo clean +fmt: + cargo fmt + lint: cargo clippy diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index b47b3a6..f1ed050 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -227,149 +227,3 @@ impl std::fmt::Debug for Line { f.write_str(&s) } } - -// impl Line { -// /// Create a new line with the length/size of `width`. -// /// -// /// Filled with `span`. -// #[must_use] -// pub fn new(width: usize, span: Span) -> Self { -// Self(vec![span; width]) -// } - -// /// Iterates over all the [`Cell`]s within the line and sets them to [`Cell::default()`]. -// pub fn reset(&mut self) { -// self.0.iter_mut().for_each(|span| { -// span.reset(); -// }); -// } - -// /// Create a new line with a single [`Span`] reserved with a capacity of -// /// `width`, does not fill [`Self`] with any [`Cell`]s, just reserves space. -// /// -// /// [`Cell`]: crate::ui::Cell -// #[must_use] -// pub fn reserve_new(width: usize) -> Self { -// Self(vec![Span::reserve_new(width, None, None); 1]) -// } - -// /// Iterates over the [`Cell`]s to index `idx` within [`Self`] -// /// and sets them to [`Cell::default()`]. -// pub fn reset_to(&mut self, idx: usize) { -// let (span, offset) = self.span_at_col(idx); -// self.0[..=span] -// .iter_mut() -// .for_each(|cell| *cell = Cell::default()); -// } - -// /// Iterates over the [`Cell`]s from index `idx` within [`Self`] -// /// to the end of [`Self`] and sets them to [`Cell::default()`]. -// pub fn reset_from(&mut self, idx: usize) { -// self.0 -// .iter_mut() -// .skip(idx) -// .for_each(|cell| *cell = Cell::default()); -// } -// -// /// Sets the character in [`Cell`] at [`Self`]\[`idx`\] to `ch`. -// pub fn set_char(&mut self, idx: usize, ch: char) { -// self.0[idx].character = ch; -// } - -// /// Returns the index of the last cell within a line where the [`Cell`] != [`Cell::EMPTY`] -// pub fn last_cell_idx(&self) -> usize { -// if self.0.is_empty() { -// return 0; -// } -// let mut offset = self.0.iter().map(|s| s.cells.len()).sum::(); -// for span in self.0.iter().rev() { -// offset -= span.cells.len(); -// if let Some(pos) = span.cells.iter().rposition(|c| *c != Cell::EMPTY) { -// return offset + pos; -// } -// } -// 0 -// } -// } - -// #[cfg(test)] -// mod tests { -// use super::*; -// -// #[test] -// fn empty_single_span() { -// let span1 = Span::new_empty(80); -// let line1 = Line(vec![span1; 1]); -// -// assert_eq!(line1.last_cell_idx(), 0); -// } -// -// #[test] -// fn empty_multi_span() { -// let span1 = Span::new_empty(20); -// let span2 = Span::new_empty(20); -// let span3 = Span::new_empty(20); -// let span4 = Span::new_empty(20); -// let line1 = Line(vec![span1, span2, span3, span4]); -// -// assert_eq!(line1.last_cell_idx(), 0); -// } -// -// #[test] -// fn middle_span_filled() { -// let span1 = Span::new_empty(20); -// let span2 = Span::new_empty(20); -// let mut span3 = Span::new_empty(20); -// let span4 = Span::new_empty(20); -// -// let cell = Cell { -// character: 'a', -// is_selected: false, -// }; -// -// *span3.cells.get_mut(10).unwrap() = cell.clone(); -// -// let line1 = Line(vec![span1, span2, span3, span4]); -// -// assert_eq!(line1.last_cell_idx(), 50); -// assert_eq!(line1.get_span(2).unwrap().cells.get(10).unwrap(), &cell); -// } -// -// #[test] -// fn last_span_filled() { -// let span1 = Span::new_empty(20); -// let span2 = Span::new_empty(20); -// let mut span3 = Span::new_empty(20); -// -// let cell = Cell { -// character: 'a', -// is_selected: false, -// }; -// -// *span3.cells.get_mut(10).unwrap() = cell.clone(); -// -// let line1 = Line(vec![span1, span2, span3]); -// -// assert_eq!(line1.last_cell_idx(), 50); -// assert_eq!(line1.get_span(2).unwrap().cells.get(10).unwrap(), &cell); -// } -// -// #[test] -// fn first_span_filled() { -// let mut span1 = Span::new_empty(20); -// let span2 = Span::new_empty(20); -// let span3 = Span::new_empty(20); -// -// let cell = Cell { -// character: 'a', -// is_selected: false, -// }; -// -// *span1.cells.get_mut(10).unwrap() = cell.clone(); -// -// let line1 = Line(vec![span1, span2, span3]); -// -// assert_eq!(line1.last_cell_idx(), 10); -// assert_eq!(line1.get_span(0).unwrap().cells.get(10).unwrap(), &cell); -// } -// } diff --git a/sericom-core/src/serial_actor/mod.rs b/sericom-core/src/serial_actor/mod.rs index fd83b5f..5f3405b 100644 --- a/sericom-core/src/serial_actor/mod.rs +++ b/sericom-core/src/serial_actor/mod.rs @@ -3,8 +3,7 @@ pub mod tasks; -/// Represents messages/commands that are sent from worker tasks -/// to the [`SerialActor`] to process. +/// Represents messages/commands that are sent from worker tasks to the [`SerialActor`] to process. #[non_exhaustive] #[derive(Debug)] pub enum SerialMessage { @@ -26,6 +25,11 @@ pub enum SerialEvent { /// Sends the error message received by the [`SerialActor`] to its tasks to handle. Error(String), /// Tells the [`SerialActor`]s tasks that the serial connection has been closed. + /// + /// This serves a different purpose from [`SerialMessage::Shutdown`] where + /// [`SerialMessage::Shutdown`] is mean to instruct the [`SerialActor`] to + /// shutdown the connection. `ConnectionClosed` is used for the [`SerialActor`] + /// to broadcast to listeners that the connection has been shutdown by the device. ConnectionClosed, } @@ -45,7 +49,8 @@ pub struct SerialActor { impl SerialActor { /// Constructs a [`SerialActor`] Takes a serial port connection, /// receiver to a command channel, and a sender to a broadcast channel. - pub fn new( + #[must_use] + pub const fn new( connection: serial2_tokio::SerialPort, command_rx: tokio::sync::mpsc::Receiver, broadcast_channel: tokio::sync::broadcast::Sender, @@ -67,6 +72,7 @@ impl SerialActor { /// Since data is sent byte-by-byte over a serial connection, `run` will /// batch the data before sending it to other tasks to reduce the number of syscalls. pub async fn run(mut self) { + tracing::trace!(target: "session::actor", "running SerialActor"); let mut buffer = vec![0u8; 4096]; loop { tokio::select! { @@ -79,9 +85,11 @@ impl SerialActor { } } Some(SerialMessage::Shutdown) => { + tracing::debug!(target: "session::actor", "recieved shutdown command"); self.broadcast_channel.send(SerialEvent::ConnectionClosed).ok(); } Some(SerialMessage::SendBreak) => { + tracing::debug!(target: "session::actor", "sending break signal"); self.send_break().await; } None => break, @@ -91,6 +99,7 @@ impl SerialActor { read_result = self.connection.read(&mut buffer) => { match read_result { Ok(0) => { + tracing::debug!(target: "session::actor", "no bytes read - connection closed"); self.broadcast_channel.send(SerialEvent::ConnectionClosed).ok(); break; } @@ -99,6 +108,7 @@ impl SerialActor { self.broadcast_channel.send(SerialEvent::Data(data)).ok(); } Err(e) => { + tracing::warn!(target: "session::actor", "error reading from connection."); self.broadcast_channel.send(SerialEvent::Error(e.to_string())).ok(); break; } @@ -108,7 +118,7 @@ impl SerialActor { } } - async fn send_break(&mut self) { + async fn send_break(&self) { use tokio::time::{Duration, sleep}; let _ = self.connection.set_break(true); sleep(Duration::from_millis(500)).await; diff --git a/sericom-core/src/session.rs b/sericom-core/src/session.rs deleted file mode 100644 index 6cae199..0000000 --- a/sericom-core/src/session.rs +++ /dev/null @@ -1,114 +0,0 @@ -#![allow(unused)] - -use std::{ - collections::HashMap, - sync::{Arc, atomic::AtomicBool}, -}; - -use tokio::task::JoinSet; - -use crate::{ - cli::open_connection, - screen::{Rect, ScreenBuffer}, - serial_actor::{SerialActor, SerialEvent, SerialMessage, tasks::run_stdin_input}, -}; - -pub type SessionID = String; - -pub struct SessionManager { - pub(crate) active: Option, - pub(crate) sessions: HashMap, -} - -pub struct SessionHandle { - pub(crate) buffer: Arc>, - /// Channel for communication from outside the session to the session - pub(crate) tx: tokio::sync::mpsc::Sender, - pub(crate) events: tokio::sync::broadcast::Receiver, - /// A sessions async tasks - pub(crate) tasks: JoinSet<()>, - pub(crate) meta: SessionMeta, -} - -pub struct SessionMeta { - pub(crate) baud: u32, -} - -impl SessionManager { - pub fn new() -> Self { - SessionManager { - active: None, - sessions: HashMap::new(), - } - } - - pub fn spawn<'a>(&mut self, port: &'a str, baud: u32) -> SessionID { - match self.sessions.keys().find(|k| *k == port) { - Some(session) => return session.clone(), - None => { - let (new_id, new_session); - new_id = port.to_owned(); - new_session = SessionHandle::new(port, baud); - - self.sessions.insert(new_id.clone(), new_session); - return new_id; - } - } - } -} - -impl SessionHandle { - pub fn new<'a>(port: &'a str, baud: u32) -> Self { - let (tx, rx) = tokio::sync::mpsc::channel::(100); - let (events_tx, _) = tokio::sync::broadcast::channel::(128); - - let (session_events, parser_events); - session_events = events_tx.subscribe(); - parser_events = events_tx.subscribe(); - - let connection = open_connection(baud, port).unwrap(); - let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 20)); - - let sb = ScreenBuffer::new(Rect::new((0u16, 0u16).into(), term_w, term_h)); - let buffer = Arc::new(tokio::sync::RwLock::new(sb)); - let actor = SerialActor::new(connection, rx, events_tx); - - let mut tasks = JoinSet::new(); - tasks.spawn(actor.run()); - tasks.spawn(parse_task(Arc::clone(&buffer), parser_events)); - - Self { - buffer, - tx, - events: session_events, - tasks, - meta: SessionMeta { baud }, - } - } -} - -async fn parse_task( - buffer: Arc>, - mut events: tokio::sync::broadcast::Receiver, -) { - use crate::screen::{ByteParser, ScreenDriver}; - - let mut parser = ByteParser::new(); - - while let Ok(event) = events.recv().await { - match event { - SerialEvent::Data(bytes) => { - let parsed = parser.feed(&bytes); - - // Short-lived write lock - { - let mut buf = buffer.write().await; - let mut driver = ScreenDriver::new(&mut buf); - driver.process_events(parsed); - } - } - SerialEvent::Error(_) => todo!(), - SerialEvent::ConnectionClosed => break, - } - } -} diff --git a/sericom-core/src/session/handle.rs b/sericom-core/src/session/handle.rs new file mode 100644 index 0000000..420a214 --- /dev/null +++ b/sericom-core/src/session/handle.rs @@ -0,0 +1,141 @@ +use std::sync::Arc; +use tokio::task::JoinSet; +use tracing::Instrument; + +use crate::{ + cli::open_connection, + screen::{Rect, ScreenBuffer}, + serial_actor::{SerialActor, SerialEvent, SerialMessage}, +}; + +/// A handle to a session - used to manage the session in headless/interactive modes. +#[derive(Debug)] +pub struct SessionHandle { + /// The Session's dedicated [`ScreenBuffer`]. + pub(crate) buffer: Arc>, + /// Channel for communication from outside the session to the session. + /// + /// A [`Sender`] so that the [`SessionHandle`] can send [`Write`], [`SendBreak`], + /// and [`Shutdown`] events to the session's [`SerialActor`]. + /// + /// [`Sender`]: tokio::sync::mpsc::Sender + /// [`Write`]: crate::serial_actor::SerialMessage::Write + /// [`SendBreak`]: crate::serial_actor::SerialMessage::SendBreak + /// [`Shutdown`]: crate::serial_actor::SerialMessage::Shutdown + pub(crate) tx: tokio::sync::mpsc::Sender, + /// A [`Receiver`] of the session's [`SerialEvent`]s. + /// + /// Intended for the [`SessionManager`] to recieve these events based on the + /// session's state and where it's received data is being written to handle accordingly. + /// + /// [`Receiver`]: tokio::sync::broadcast::Receiver + /// [`SessionManager`]: super::SessionManager + pub(crate) events: tokio::sync::broadcast::Receiver, + /// A sessions async tasks. + /// + /// Currently a session only has two tasks, the [`SerialActor::run`] and + /// the task responsible for parsing the session's incoming data/byte stream. + /// The [`JoinSet`] is used so that when a session is terminated, all tasks + /// associated with a session can be gracefully killed together. + pub(crate) tasks: JoinSet<()>, +} + +impl SessionHandle { + /// Spawn a new session for `port` with `baud`. + #[allow(clippy::result_unit_err)] + pub fn spawn(meta: &super::SessionMeta) -> miette::Result { + let (tx, rx) = tokio::sync::mpsc::channel::(100); + let (events_tx, _) = tokio::sync::broadcast::channel::(128); + + let session_events = events_tx.subscribe(); + let parser_events = events_tx.subscribe(); + + let connection = open_connection(meta.baud, &meta.port)?; // TODO: HANDLE ERROR + let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 20)); + + let sb = ScreenBuffer::new(Rect::new((0u16, 0u16).into(), term_w, term_h)); + let buffer = Arc::new(tokio::sync::RwLock::new(sb)); + let actor = SerialActor::new(connection, rx, events_tx); + + // Not storing the span in SessionHandle because I'm not sure if I'll + // ever need to emit events outside of the SessionHandle in the same span. + // If I do I could just re-create the span from SessionMeta. + let span = tracing::info_span!("session", port = %meta.port, baud = %meta.baud); + let _enter = span.enter(); + tracing::info!(target: "session", "Opened connection"); + + let mut tasks = JoinSet::new(); + tasks.spawn(actor.run().instrument(span.clone())); + tasks.spawn(parse_task(Arc::clone(&buffer), parser_events).instrument(span.clone())); + + Ok(Self { + buffer, + tx, + events: session_events, + tasks, + }) + } + + pub async fn shutdown(self) { + let _ = self.tx.send(SerialMessage::Shutdown).await; + self.tasks.join_all().await; + } +} + +async fn parse_task( + buffer: Arc>, + mut events: tokio::sync::broadcast::Receiver, +) { + use crate::screen::{ByteParser, ScreenDriver}; + + let mut parser = ByteParser::new(); + + while let Ok(event) = events.recv().await { + match event { + SerialEvent::Data(bytes) => { + let parsed = parser.feed(&bytes); + + let mut buf = buffer.write().await; + ScreenDriver::new(&mut buf).process_events(parsed); + } + SerialEvent::ConnectionClosed => { + tracing::info!(target: "session::parse_task", "Connection closed"); + break; + } + _ => unreachable!("SerialActor never sends a SerialEvent::Error"), + } + } +} + +/* +#[test] +fn sizes() { + eprintln!( + "size of handle: {}\n + align of handle: {}\n + size of buffer: {}\n + align of buffer: {}\n + size of tx: {}\n + align of tx: {}\n + size of events: {}\n + align of events: {}\n + size of tasks: {}\n + align of tasks: {}\n + size of Span: {}\n + align of Span: {}", + size_of::(), + align_of::(), + size_of::>>(), + align_of::>>(), + size_of::>(), + align_of::>(), + size_of::>(), + align_of::>(), + size_of::>(), + align_of::>(), + size_of::(), + align_of::(), + ); + assert!(1 > 2); +} +*/ diff --git a/sericom-core/src/session/mod.rs b/sericom-core/src/session/mod.rs new file mode 100644 index 0000000..0ea5905 --- /dev/null +++ b/sericom-core/src/session/mod.rs @@ -0,0 +1,152 @@ +mod handle; +pub use handle::SessionHandle; +use miette::IntoDiagnostic; + +pub type SessionID = u8; + +#[derive(Debug, Default)] +pub struct SessionManager { + handles: Vec, + metas: Vec, + active: Option, +} + +/// Includes the baud rate and name of the port/connection. +/// +/// TODO: Possible include [`Appearance`] here too so that each +/// session's default colors can be set by CLI flags? +/// +/// [`Appearance`]: crate::configs::Appearance +#[derive(Debug, Clone)] +pub struct SessionMeta { + pub(crate) baud: u32, + pub(crate) port: String, +} + +impl SessionManager { + #[must_use] + pub fn new() -> Self { + Self { + active: None, + // Creating with capacity of ~25 because if someone has more than 25 + // sessions they can get an allocation - majority will have < 25 sessions + handles: Vec::with_capacity((u8::MAX / 10u8) as usize), + metas: Vec::with_capacity((u8::MAX / 10u8) as usize), + } + } + + /// Get a reference to the [`SessionHandle`] of [`SessionID`]. + /// + /// Returns `None` if the [`SessionID`] is invalid. + #[must_use] + pub fn get_session(&self, id: SessionID) -> Option<&SessionHandle> { + self.handles.get(id as usize) + } + + /// Get a reference to the [`SessionMeta`] of [`SessionID`]. + /// + /// Returns `None` if the [`SessionID`] is invalid. + #[must_use] + pub fn get_meta(&self, id: SessionID) -> Option<&SessionMeta> { + self.metas.get(id as usize) + } + + /// Create a session at `port` with the specified `baud` rate. + /// + /// # Errors + /// Errors if there are already a maximum number of sessions ([`u8::MAX`]) or + /// if the [`SessionHandle::spawn`] errors. + /// TODO: HANDLE ERROR PROPAGATING TO STDOUT + #[allow(clippy::result_unit_err)] + #[allow(clippy::cast_possible_truncation)] + pub fn spawn(&mut self, port: &str, baud: u32) -> miette::Result { + let id = self.metas.len(); + + if !id < u8::MAX as usize { + return Err(()).map_err(|_| miette::miette!("Max sessions reached"))?; // TODO: HANDLE STDOUT ERROR PROPAGATING + } + + let meta = SessionMeta { + baud, + port: port.to_owned(), + }; + let handle = SessionHandle::spawn(&meta)?; + + self.handles.push(handle); + self.metas.push(meta); + + // Cast is fine, verified that id is < u8::MAX + Ok(id as SessionID) + } + + /// Kill/shutdown the session for [`SessionID`] + #[allow(clippy::cast_possible_truncation)] + pub async fn kill(&mut self, id: SessionID) { + let idx = id as usize; + if idx >= self.metas.len() { + return; + } + + if self.handles.get(idx).is_none() { + return; + } + + self.metas.swap_remove(idx); + self.handles.swap_remove(idx).shutdown().await; + + // NOTE: + // Shouldn't need to adjust active idx because when the user is in the + // REPL, there should not be an active session; and this should only + // be possible to call from the REPL. Check anyways in the off-chance + // that this is not the case. + debug_assert!(self.active.is_none()); + if let Some(active) = self.active { + let a_idx = active as usize; + if a_idx == idx { + self.active = None; + } else if a_idx == self.metas.len() { + // Just moved last item into idx, renumber active to maintain + // Cast is fine, verified that id is < u8::MAX + self.active = Some(idx as SessionID); + } + } + } + + /// List the sessions stored in [`SessionManager`], writes to the given [`Writer`]. + /// + /// Writes in the following format: + /// ```txt + /// ID PORT BAUD + /// 1 /dev/ttyUSB1 9600 + /// 2 /dev/ttyUSB2 115200 + /// ``` + /// + /// [`Writer`]: std::io::Write + pub fn list(&self, writer: &mut W) { + #[cfg(target_os = "windows")] + { + let _ = writeln!(writer, "{:3} {:6} BAUD", "ID", "PORT"); + for (id, meta) in self.metas.iter().enumerate() { + let _ = writeln!(writer, "{:<3} {:6} {}", id, meta.port, meta.baud); + } + } + + #[cfg(not(target_os = "windows"))] + { + let _ = writeln!(writer, "{:3} {:14} BAUD", "ID", "PORT"); + for (id, meta) in self.metas.iter().enumerate() { + let _ = writeln!(writer, "{:<3} {:14} {}", id, meta.port, meta.baud); + } + } + } + + pub async fn graceful_shutdown(self) -> miette::Result<()> { + let mut idx: u8 = 0; + for session in self.handles { + idx += 1; + session.shutdown().await; + tracing::info!(target: "session", port = %self.metas[idx as usize].port, "Shut down session {idx}"); + } + Ok(()) + } +} From 8fca75f8aa66608b82c6f7c19ed1657dd5f1fe6d Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Fri, 12 Dec 2025 11:43:43 -0600 Subject: [PATCH 27/40] refactor(repl): Restructured sericom's commands/cli interface Reformatting sericom's CLI API for this new REPL implementation. Working towards refactoring the starting/stopping of a session. changelog: ignore --- sericom/src/main.rs | 290 ++++++++++++++++++++++++++++---------------- 1 file changed, 185 insertions(+), 105 deletions(-) diff --git a/sericom/src/main.rs b/sericom/src/main.rs index 937c213..3cbdbf2 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -8,25 +8,18 @@ //! configuration, resetting, getting statistics, etc. use clap::{CommandFactory, Parser, Subcommand}; -use crossterm::style::Stylize; use miette::{Context, IntoDiagnostic}; use sericom_core::{ - cli::{ - color_parser, get_settings, interactive_session, list_serial_ports, open_connection, - valid_baud_rate, - }, + cli::{color_parser, list_serial_ports, valid_baud_rate}, configs::{get_config, initialize_config}, path_utils::{is_script, validate_dir}, }; -use std::{ - fmt::Display, - io::{self, Write}, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; const RED: &str = "\x1b\x5b31m"; const BOLD: &str = "\x1b\x5b1m"; const UNBOLD: &str = "\x1b\x5b22m"; +const DIM: &str = "\x1b\x5b2m"; const RESET: &str = "\x1b\x5b0m"; #[derive(Parser)] @@ -34,14 +27,14 @@ const RESET: &str = "\x1b\x5b0m"; #[command(propagate_version = true)] struct Cli { #[command(subcommand)] - command: Option, + command: Commands, } #[allow(clippy::enum_variant_names)] #[derive(Subcommand)] enum Commands { /// Connect to a serial port - #[command(alias = "c")] + #[command(alias = "c", alias = "con")] Connect { /// The path to a serial port. /// @@ -55,22 +48,44 @@ enum Commands { /// Path to a file for the output. #[arg(short, long)] file: Option>, - /// Display debug output + /// Start the session in the background (headless) + #[arg(long)] + bg: bool, + /// Write debug output #[arg(short, long)] debug: bool, }, - /// Lists valid baud rates - Bauds, - /// Lists all available serial ports - Ports, - /// Gets the settings for a serial port - Settings { - #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] - baud: u32, - /// Path to the port to open - #[arg(short, long)] - port: String, + /// List helpful information like valid baud rates, available serial ports, and sessions. + #[command(alias = "ls", alias = "l")] + List { + #[command(subcommand)] + cmd: ListCmds, }, + // TODO: Use this to print user settings like `git config --list` + // Set { + // Stuff to set settings + // } + // Settings { + // #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] + // baud: u32, + // /// Path to the port to open + // #[arg(short, long)] + // port: String, + // }, +} + +#[derive(Subcommand)] +enum ListCmds { + /// List available serial ports. + #[command(alias = "p")] + Ports, + /// List valid baud rates. + #[command(alias = "b")] + Bauds, + #[command(alias = "s", alias = "sess")] + Sessions, + #[command(alias = "set")] + Settings, } #[derive(Parser, Debug)] @@ -98,17 +113,45 @@ impl From for sericom_core::configs::ConfigOverride { } } -async fn run_repl() -> miette::Result<()> { +const CONFIG_OVERRIDE: sericom_core::configs::ConfigOverride = + sericom_core::configs::ConfigOverride { + color: None, + out_dir: None, + exit_script: None, + }; + +#[tokio::main] +async fn main() -> miette::Result<()> { + let mut manager = sericom_core::session::SessionManager::new(); + initialize_config(CONFIG_OVERRIDE)?; + let _trace_guard: Option = { + let config = get_config(); + let out_dir = config.defaults.debug_dir.as_path(); + init_tracing(out_dir, false)? // TODO: GET DEBUG CLI FLAG HERE + }; + + tokio::select! { + result = run_repl(&mut manager) => result, + () = shutdown_signal() => { + tracing::info!("got shutdown signal"); + manager.graceful_shutdown().await + } + } +} + +async fn run_repl(mut manager: &mut sericom_core::session::SessionManager) -> miette::Result<()> { let conf = rustyline::Config::builder() .history_ignore_space(true) .build(); - let mut rl = rustyline::DefaultEditor::with_config(conf).expect("Failed to create REPL"); + let mut rl = rustyline::DefaultEditor::with_config(conf) + .into_diagnostic() + .wrap_err("Failed to create REPL")?; let history_path = std::env::home_dir() .unwrap_or(PathBuf::from("./")) - .join(".repl_history"); + .join(".sericom_history"); if rl.load_history(&history_path).is_err() { - println!("No previous history"); + println!("{DIM}No previous history{RESET}"); } println!("Welcome to the sericom, type 'exit' to quit."); @@ -124,7 +167,7 @@ async fn run_repl() -> miette::Result<()> { rl.add_history_entry(line).ok(); - if line == "exit" { + if line == "exit" || line == "q" || line == "quit" { break; } @@ -136,12 +179,10 @@ async fn run_repl() -> miette::Result<()> { let args = format!("sericom {}", line); let cli = Cli::try_parse_from(args.split_whitespace()); match cli { - Ok(cli) => { - if let Some(cmd) = cli.command { - handle_cmds(cmd).await? - } + Ok(cli) => handle_cmds(cli.command, &mut manager).await?, + Err(e) => { + print!("{e}"); } - Err(e) => eprintln!("{e}"), } } Err(_) => break, @@ -152,6 +193,65 @@ async fn run_repl() -> miette::Result<()> { Ok(()) } +async fn handle_cmds( + cmd: Commands, + manager: &mut sericom_core::session::SessionManager, +) -> miette::Result<()> { + let mut stdout = std::io::stdout(); + match cmd { + Commands::Connect { + port, + baud, + config_override, + file, + bg, + debug, + } => { + manager + .spawn(&port, baud) + .wrap_err("Failed to set subscriber")?; + // let connection = open_connection(baud, &port)?; + // let overrides: sericom_core::configs::ConfigOverride = config_override.into(); + // + // if let Some(Some(path)) = &file + // && path.is_dir() + // { + // return Err(miette::miette!( + // "Could not create file at: '{}' because it is a directory.", + // path.display() + // )); + // } + // initialize_config(overrides)?; + // // Need to hold the guard in `main`'s scope + // let _guard: Option = if debug { + // let config = get_config(); + // let out_dir = config.defaults.debug_dir.as_path(); + // init_tracing(out_dir)? + // } else { + // None + // }; + // interactive_session(connection, file, &port).await?; + Ok(()) + } + Commands::List { cmd } => match cmd { + ListCmds::Ports => list_serial_ports(), + ListCmds::Bauds => { + println!("Valid baud rates:"); + for baud in serial2_tokio::COMMON_BAUD_RATES { + println!("{baud}"); + } + Ok(()) + } + ListCmds::Sessions => { + manager.list(&mut stdout); + Ok(()) + } + ListCmds::Settings => todo!(), + }, + // Commands::Settings { baud, port } => get_settings(baud, &port), + } +} + fn handle_help(line: &str) { let mut cmd = Cli::command(); let tokens: Vec<&str> = line.split_whitespace().collect(); @@ -176,77 +276,13 @@ fn handle_help(line: &str) { println!("{RED}No help found for '{BOLD}{line}{UNBOLD}'.{RESET}"); } -#[tokio::main] -async fn main() -> miette::Result<()> { - let cli = Cli::parse(); - - if let Some(cmd) = cli.command { - handle_cmds(cmd).await? - } else { - run_repl().await? - } - Ok(()) -} - -async fn handle_cmds(cmd: Commands) -> miette::Result<()> { - match cmd { - Commands::Connect { - port, - baud, - config_override, - file, - debug, - } => { - let connection = open_connection(baud, &port)?; - let overrides: sericom_core::configs::ConfigOverride = config_override.into(); - - if let Some(Some(path)) = &file - && path.is_dir() - { - return Err(miette::miette!( - "Could not create file at: '{}' because it is a directory.", - path.display() - )); - } - initialize_config(overrides)?; - // Need to hold the guard in `main`'s scope - let _guard: Option = if debug { - let config = get_config(); - let out_dir = config.defaults.debug_dir.as_path(); - init_tracing(out_dir, &port)? - } else { - None - }; - interactive_session(connection, file, &port).await?; - Ok(()) - } - Commands::Bauds => { - let mut stdout = io::stdout(); - write!(stdout, "Valid baud rates:\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - for baud in serial2_tokio::COMMON_BAUD_RATES { - write!(stdout, "{baud}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - } - Ok(()) - } - Commands::Ports => list_serial_ports(), - Commands::Settings { baud, port } => get_settings(baud, &port), - } -} - -fn init_tracing( +fn init_tracing( out_dir: &Path, - port: S, -) -> miette::Result> -where - S: AsRef + Display + Into, -{ + _debug: bool, +) -> miette::Result> { use sericom_core::compat_port_path; - let path = compat_port_path!(out_dir, port, prefix = "trace"); + let path = compat_port_path!(out_dir); let file = std::fs::File::options() .write(true) .create(true) @@ -254,16 +290,60 @@ where .open(&path) .into_diagnostic() .wrap_err_with(|| format!("Failed to create '{}'", path.display()))?; + let (non_blocking, guard) = tracing_appender::non_blocking(file); let subscriber = tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + .with_max_level({ + #[cfg(debug_assertions)] + { + tracing::Level::TRACE + } + #[cfg(not(debug_assertions))] + { + if _debug { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + } + } + }) .with_writer(non_blocking) - // .without_time() .with_line_number(false) - .with_target(false) + .with_target(true) .finish(); + tracing::subscriber::set_global_default(subscriber) .into_diagnostic() .wrap_err("Failed to set subscriber")?; Ok(Some(guard)) } + +async fn shutdown_signal() { + use tokio::signal::{self, unix::SignalKind}; + + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + }; + + let terminate = async { + signal::unix::signal(SignalKind::terminate()) + .expect("Failed to install signal handler") + .recv() + .await + }; + + let quit = async { + signal::unix::signal(SignalKind::quit()) + .expect("Failed to install signal handler") + .recv() + .await + }; + + tokio::select! { + () = ctrl_c => {}, + _ = terminate => {}, + _ = quit => {}, + } +} From 8afefafc4ca00505403849669aa2cf0e9014eedf Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Fri, 12 Dec 2025 23:30:26 -0600 Subject: [PATCH 28/40] refactor(repl): Change global config to use RwLock to mutate from REPL Got rid of the `tokio::select!` in `sericom::main()` and shuffled state to be held in `sericom::run_repl()` for easier usability - works for now. Also removed some match arms of `map_miette!` macro. The rest is just updating the tests to reflect these changes. --- sericom-core/src/cli.rs | 94 ++++----------------- sericom-core/src/configs/mod.rs | 36 +++++--- sericom-core/src/path_utils/macros.rs | 31 ++----- sericom-core/src/screen/components/span.rs | 3 +- sericom-core/src/screen/process/colors.rs | 2 +- sericom-core/src/screen/tests/colors.rs | 40 ++++----- sericom-core/src/screen/tests/components.rs | 20 +++-- sericom-core/src/screen/tests/mod.rs | 6 +- sericom-core/src/session/mod.rs | 3 +- sericom/src/main.rs | 58 +++---------- 10 files changed, 99 insertions(+), 194 deletions(-) diff --git a/sericom-core/src/cli.rs b/sericom-core/src/cli.rs index 7daf998..bd7f5f5 100644 --- a/sericom-core/src/cli.rs +++ b/sericom-core/src/cli.rs @@ -48,7 +48,7 @@ pub async fn interactive_session( ) .into_diagnostic() .wrap_err("Failed to setup the terminal.".red())?; - let config = get_config(); + let config = get_config().unwrap(); // TODO: HANDLE trace!("Creating channels"); // Create channels @@ -82,7 +82,7 @@ pub async fn interactive_session( let file_rx = broadcast_event_tx.subscribe(); tasks.spawn(async move { run_file_output(file_rx, file_path.clone()).await; - run_file_exit_script(config, file_path); + run_file_exit_script(file_path); }); } @@ -113,14 +113,9 @@ pub fn open_connection(baud: u32, port: &str) -> miette::Result { let con = map_miette!( SerialPort::open(port, settings), format!("Failed to open port '{}'", port), - format!( - "{} {} [OPTIONS] [PORT] [COMMAND]", - "USAGE:".bold().underlined(), - "sericom".bold() - ), help = format!( "To see available ports, try `{}`.", - "sericom list-ports".bold().cyan() + "list ports".bold().cyan() ) )?; Ok(con) @@ -134,104 +129,44 @@ pub fn get_settings(baud: u32, port: &str) -> miette::Result<()> { let con = open_connection(baud, port)?; let settings = map_miette!( con.get_configuration(), - format!("Failed to get settings for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get settings for port '{}'", port) )?; let b = map_miette!( settings.get_baud_rate(), - format!("Failed to get the baud rate for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get the baud rate for port '{}'", port) )?; let c = map_miette!( settings.get_char_size(), - format!("Failed to get the char size for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get the char size for port '{}'", port) )?; let s = map_miette!( settings.get_stop_bits(), - format!("Failed to get stop bits for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get stop bits for port '{}'", port) )?; let p = map_miette!( settings.get_parity(), - format!("Failed to get parity for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get parity for port '{}'", port) )?; let f = map_miette!( settings.get_flow_control(), - format!("Failed to get flow control for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to get flow control for port '{}'", port) )?; let cts = map_miette!( con.read_cts(), - format!("Failed to read CTS for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to read CTS for port '{}'", port) )?; let dsr = map_miette!( con.read_dsr(), - format!("Failed to read DSR for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to read DSR for port '{}'", port) )?; let ri = map_miette!( con.read_ri(), - format!("Failed to read RI for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to read RI for port '{}'", port) )?; let cd = map_miette!( con.read_cd(), - format!("Failed to read CD for port '{}'", port), - format!( - "{} {} [OPTIONS] {} ", - "USAGE:".bold().underlined(), - "sericom list-settings".bold(), - "--port".bold() - ) + format!("Failed to read CD for port '{}'", port) )?; write!(stdout, "Baud rate: {b}\r\n") @@ -333,7 +268,8 @@ fn ensure_terminal_cleanup(mut stdout: io::Stdout) { let _ = stdout.flush(); } -fn run_file_exit_script(config: &'static crate::configs::Config, file_path: PathBuf) { +fn run_file_exit_script(file_path: PathBuf) { + let config = crate::configs::get_config().unwrap(); //TODO: HANDLE let span = tracing::span!(Level::DEBUG, "Exit script"); let _enter = span.enter(); diff --git a/sericom-core/src/configs/mod.rs b/sericom-core/src/configs/mod.rs index 9f785b7..deaca27 100644 --- a/sericom-core/src/configs/mod.rs +++ b/sericom-core/src/configs/mod.rs @@ -13,7 +13,12 @@ use crate::{ create_recursive, }; use serde::Deserialize; -use std::{io::Read, ops::Range, path::PathBuf, sync::OnceLock}; +use std::{ + io::Read, + ops::Range, + path::PathBuf, + sync::{LockResult, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard, TryLockResult}, +}; /// Global value of the user's config. /// @@ -21,7 +26,7 @@ use std::{io::Read, ops::Range, path::PathBuf, sync::OnceLock}; /// underlying [`Config`] must be made before calling [`initialize_config()`]. /// /// To get a reference to the global config during runtime, call [`get_config()`]. -pub static CONFIG: OnceLock = OnceLock::new(); +pub static CONFIG: OnceLock> = OnceLock::new(); /// Represents the entire `config.toml` configuration file. /// @@ -60,7 +65,7 @@ impl Config { /// /// Returns a [`ConfigError::AlreadyInitialized`] error if called after it has /// already been called ([`CONFIG`] has already been set). -pub fn initialize_config(overrides: ConfigOverride) -> miette::Result<(), ConfigError> { +pub fn initialize_config(overrides: Option) -> miette::Result<(), ConfigError> { let mut config: Config = if let Ok(config_file) = get_config_file() { let mut file = std::fs::File::open(config_file).expect("File should exist"); let mut contents = String::new(); @@ -76,14 +81,17 @@ pub fn initialize_config(overrides: ConfigOverride) -> miette::Result<(), Config Config::default() }; - config.apply_overrides(overrides); + if let Some(overrides) = overrides { + config.apply_overrides(overrides); + }; CONFIG - .set(config) + .set(RwLock::new(config)) .map_err(|_| ConfigError::AlreadyInitialized)?; Ok(()) } +// TODO: UPDATE DOCS /// When called, [`get_config()`] returns a reference to the global [`CONFIG`] /// that was initialized at the start of the program. /// @@ -91,8 +99,17 @@ pub fn initialize_config(overrides: ConfigOverride) -> miette::Result<(), Config /// /// ## Panics /// Will panic if [`CONFIG`] as not been initialized before calling with [`initialize_config()`]. -pub fn get_config() -> &'static Config { - CONFIG.get().expect("Config not initialized") +pub fn get_config<'a>() -> LockResult> { + // thinking is not try_read because when this method is called, it is + // called because the values _are needed_ for initializing other things + // so returning an Err from try_read is not helpful - would rather have it + // block until it gets a read lock than get an err if it wasn't ready + CONFIG.get().expect("Config not initialized").read() +} + +// TODO: UPDATE DOCS +pub fn get_mut_config<'a>() -> LockResult> { + CONFIG.get().expect("Config not initialized").write() } #[derive(Debug)] @@ -144,7 +161,6 @@ fn parse_test_config() -> miette::Result<()> { [defaults] out-dir = "$HOME/.config" - exit-script = "~/.local/bin/format-cisco" "#, ) .into_diagnostic()?; @@ -156,9 +172,7 @@ fn parse_test_config() -> miette::Result<()> { }, defaults: Defaults { out_dir: PathBuf::from("/home/thomas/.config"), - exit_script: Some(PathBuf::from("/home/thomas/.local/bin/format-cisco")), - debug_dir: PathBuf::from("/home/thomas/Code/Work/sericom/sericom-core"), - // file_exit_script: None, + ..Default::default() }, }; diff --git a/sericom-core/src/path_utils/macros.rs b/sericom-core/src/path_utils/macros.rs index 914b046..56a59dd 100644 --- a/sericom-core/src/path_utils/macros.rs +++ b/sericom-core/src/path_utils/macros.rs @@ -66,24 +66,12 @@ macro_rules! create_recursive { /// ``` #[macro_export] macro_rules! map_miette { - // Clap-style USAGE: && additional "help" message - ($expr:expr, $wrap_msg:expr, $usage:expr, help = $add_help:expr) => { - $expr.map_err(|e| { - use crossterm::style::Stylize; - miette::miette!( - help = format!("{}\nFor more information, try `sericom --help`.", $add_help), - "{e}" - ) - .wrap_err(format!("{}\n\n{}\n", $wrap_msg, $usage).red()) - }) - }; - - // Clap-style USAGE: && default "help" message - ($expr:expr, $wrap_msg:expr, $usage:expr) => { + // Default "help" message + ($expr:expr, $wrap_msg:expr) => { $expr.map_err(|e| { use crossterm::style::Stylize; - miette::miette!(help = "For more information, try `sericom --help`.", "{e}") - .wrap_err(format!("{}\n\n{}\n", $wrap_msg, $usage).red()) + miette::miette!(help = "For more information, try `help [COMMAND]`.", "{e}") + .wrap_err(format!("{}", $wrap_msg).red()) }) }; @@ -92,21 +80,12 @@ macro_rules! map_miette { $expr.map_err(|e| { use crossterm::style::Stylize; miette::miette!( - help = format!("{}\nFor more information, try `sericom --help`.", $add_help), + help = format!("{}\nFor more information, try `help [COMMAND]`.", $add_help), "{e}" ) .wrap_err(format!("{}", $wrap_msg).red()) }) }; - - // Default "help" message - ($expr:expr, $wrap_msg:expr) => { - $expr.map_err(|e| { - use crossterm::style::Stylize; - miette::miette!(help = "For more information, try `sericom --help`.", "{e}") - .wrap_err(format!("{}", $wrap_msg).red()) - }) - }; } /// Creates the default filename if none is specified from the `port` name diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index 62a95df..ff8780c 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -52,9 +52,10 @@ impl Span { } fn get_config_colors() -> Colors { - let config = get_config(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); Colors::new(fg, bg) } diff --git a/sericom-core/src/screen/process/colors.rs b/sericom-core/src/screen/process/colors.rs index 209e2e8..8507ee0 100644 --- a/sericom-core/src/screen/process/colors.rs +++ b/sericom-core/src/screen/process/colors.rs @@ -15,7 +15,7 @@ pub struct ColorState { impl Default for ColorState { fn default() -> Self { - let config = get_config(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); Self { diff --git a/sericom-core/src/screen/tests/colors.rs b/sericom-core/src/screen/tests/colors.rs index a23f260..7ca8701 100644 --- a/sericom-core/src/screen/tests/colors.rs +++ b/sericom-core/src/screen/tests/colors.rs @@ -55,8 +55,8 @@ fn test_cases(cases: &Vec) { #[test] fn test_basic_fg() { - initialize_config(CONF_OR).ok(); - let config = get_config(); + initialize_config(Some(CONF_OR)).ok(); + let config = get_config().unwrap(); let bg = Color::from(&config.appearance.bg); let cases = vec![ @@ -80,8 +80,8 @@ fn test_basic_fg() { #[test] fn test_basic_bg() { - initialize_config(CONF_OR).ok(); - let config = get_config(); + initialize_config(Some(CONF_OR)).ok(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let cases = vec![Case { @@ -96,8 +96,8 @@ fn test_basic_bg() { #[test] fn test_bright_colors() { - initialize_config(CONF_OR).ok(); - let config = get_config(); + initialize_config(Some(CONF_OR)).ok(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -122,8 +122,8 @@ fn test_bright_colors() { #[test] fn test_resets_and_defaults() { - initialize_config(CONF_OR).ok(); - let config = get_config(); + initialize_config(Some(CONF_OR)).ok(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -155,8 +155,8 @@ fn test_resets_and_defaults() { #[test] fn test_attributes() { - initialize_config(CONF_OR).ok(); - let config = get_config(); + initialize_config(Some(CONF_OR)).ok(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -172,8 +172,8 @@ fn test_attributes() { #[test] fn test_256_color_palette() { - initialize_config(CONF_OR).ok(); - let config = get_config(); + initialize_config(Some(CONF_OR)).ok(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -198,8 +198,8 @@ fn test_256_color_palette() { #[test] fn test_truecolor_palette() { - initialize_config(CONF_OR).ok(); - let config = get_config(); + initialize_config(Some(CONF_OR)).ok(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -232,8 +232,8 @@ fn test_truecolor_palette() { #[test] fn test_mix_attr_colors() { - initialize_config(CONF_OR).ok(); - let config = get_config(); + initialize_config(Some(CONF_OR)).ok(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -262,8 +262,8 @@ fn test_mix_attr_colors() { #[test] fn test_kitchen_sink() { - initialize_config(CONF_OR).ok(); - let config = get_config(); + initialize_config(Some(CONF_OR)).ok(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); @@ -292,8 +292,8 @@ fn test_kitchen_sink() { #[test] fn test_invalid() { - initialize_config(CONF_OR).ok(); - let config = get_config(); + initialize_config(Some(CONF_OR)).ok(); + let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); diff --git a/sericom-core/src/screen/tests/components.rs b/sericom-core/src/screen/tests/components.rs index 73a1bb6..9b3a341 100644 --- a/sericom-core/src/screen/tests/components.rs +++ b/sericom-core/src/screen/tests/components.rs @@ -33,7 +33,7 @@ impl io::Write for FakeWrite { #[test] fn line_as_command() { - initialize_config(CONFIG_OVERRIDE).ok(); + initialize_config(Some(CONFIG_OVERRIDE)); let mut writer = FakeWrite { buffer: String::new(), flushed: false, @@ -53,12 +53,20 @@ fn line_as_command() { execute!(writer, line); let mut cmp = String::new(); - write!(cmp, "{}", SetColors(Colors::new(Color::Cyan, Color::Reset))); + write!( + cmp, + "{}", + SetColors(Colors::new(Color::Green, Color::Reset)) + ); write!(cmp, "first span"); write!(cmp, "{}", SetColors(Colors::new(Color::Red, Color::Reset))); write!(cmp, "{}", SetAttribute(Attribute::Bold)); write!(cmp, "second span"); - write!(cmp, "{}", SetColors(Colors::new(Color::Cyan, Color::Reset))); + write!( + cmp, + "{}", + SetColors(Colors::new(Color::Green, Color::Reset)) + ); writeln!(cmp, "third span"); assert_eq!(writer.buffer, cmp); @@ -78,7 +86,7 @@ fn overwriting_span() { #[test] fn span_newline() { - initialize_config(CONFIG_OVERRIDE); + initialize_config(Some(CONFIG_OVERRIDE)); let mut span: Span = "my test span ".chars().collect(); let res = span.last_filled_idx(); @@ -102,7 +110,7 @@ fn span_newline() { #[test] fn line_last_filled() { - initialize_config(CONFIG_OVERRIDE); + initialize_config(Some(CONFIG_OVERRIDE)); let mut span1: Span = "first span".chars().collect(); let mut span2: Span = "second span ".chars().collect(); @@ -114,7 +122,7 @@ fn line_last_filled() { #[test] fn line_num_filled() { - initialize_config(CONFIG_OVERRIDE); + initialize_config(Some(CONFIG_OVERRIDE)); let mut span1: Span = "first span".chars().collect(); let mut span2: Span = "second span ".chars().collect(); diff --git a/sericom-core/src/screen/tests/mod.rs b/sericom-core/src/screen/tests/mod.rs index 81e68d8..2dc7e22 100644 --- a/sericom-core/src/screen/tests/mod.rs +++ b/sericom-core/src/screen/tests/mod.rs @@ -20,18 +20,18 @@ pub const TERMINAL_SIZE: (u16, u16) = (80, 24); #[macro_export] macro_rules! setup { ($sb:ident, $parser:ident, $stdout:ident) => { - initialize_config(CONFIG_OVERRIDE).ok(); + initialize_config(Some(CONFIG_OVERRIDE)).ok(); let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); let mut $sb = ScreenBuffer::new(rect); let mut $parser = ByteParser::new(); let mut $stdout = std::io::stdout(); }; ($sb:ident, $parser:ident, $config:ident, $stdout:ident) => { - initialize_config(CONFIG_OVERRIDE).ok(); + initialize_config(Some(CONFIG_OVERRIDE)).ok(); let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); let mut $sb = ScreenBuffer::new(rect); let mut $parser = ByteParser::new(); - let $config = $crate::configs::get_config(); + let $config = $crate::configs::get_config().unwrap(); let mut $stdout = std::io::stdout(); }; } diff --git a/sericom-core/src/session/mod.rs b/sericom-core/src/session/mod.rs index 0ea5905..471f21b 100644 --- a/sericom-core/src/session/mod.rs +++ b/sericom-core/src/session/mod.rs @@ -140,13 +140,12 @@ impl SessionManager { } } - pub async fn graceful_shutdown(self) -> miette::Result<()> { + pub async fn graceful_shutdown(self) { let mut idx: u8 = 0; for session in self.handles { idx += 1; session.shutdown().await; tracing::info!(target: "session", port = %self.metas[idx as usize].port, "Shut down session {idx}"); } - Ok(()) } } diff --git a/sericom/src/main.rs b/sericom/src/main.rs index 3cbdbf2..7375278 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -122,24 +122,17 @@ const CONFIG_OVERRIDE: sericom_core::configs::ConfigOverride = #[tokio::main] async fn main() -> miette::Result<()> { - let mut manager = sericom_core::session::SessionManager::new(); - initialize_config(CONFIG_OVERRIDE)?; + initialize_config(None)?; let _trace_guard: Option = { - let config = get_config(); + let config = get_config().unwrap(); let out_dir = config.defaults.debug_dir.as_path(); init_tracing(out_dir, false)? // TODO: GET DEBUG CLI FLAG HERE }; - tokio::select! { - result = run_repl(&mut manager) => result, - () = shutdown_signal() => { - tracing::info!("got shutdown signal"); - manager.graceful_shutdown().await - } - } + run_repl().await } -async fn run_repl(mut manager: &mut sericom_core::session::SessionManager) -> miette::Result<()> { +async fn run_repl() -> miette::Result<()> { let conf = rustyline::Config::builder() .history_ignore_space(true) .build(); @@ -156,6 +149,8 @@ async fn run_repl(mut manager: &mut sericom_core::session::SessionManager) -> mi println!("Welcome to the sericom, type 'exit' to quit."); + let mut manager = sericom_core::session::SessionManager::new(); + loop { let readline = rl.readline(">> "); match readline { @@ -185,11 +180,16 @@ async fn run_repl(mut manager: &mut sericom_core::session::SessionManager) -> mi } } } - Err(_) => break, + Err(rustyline::error::ReadlineError::Interrupted) => continue, + Err(err) => { + tracing::debug!(target: "repl", %err, "got unknown error from rustyline"); + break; + } } } rl.save_history(&history_path).ok(); + manager.graceful_shutdown().await; Ok(()) } @@ -207,9 +207,7 @@ async fn handle_cmds( bg, debug, } => { - manager - .spawn(&port, baud) - .wrap_err("Failed to set subscriber")?; + manager.spawn(&port, baud)?; // let connection = open_connection(baud, &port)?; // let overrides: sericom_core::configs::ConfigOverride = config_override.into(); // @@ -317,33 +315,3 @@ fn init_tracing( .wrap_err("Failed to set subscriber")?; Ok(Some(guard)) } - -async fn shutdown_signal() { - use tokio::signal::{self, unix::SignalKind}; - - let ctrl_c = async { - signal::ctrl_c() - .await - .expect("Failed to install Ctrl+C handler"); - }; - - let terminate = async { - signal::unix::signal(SignalKind::terminate()) - .expect("Failed to install signal handler") - .recv() - .await - }; - - let quit = async { - signal::unix::signal(SignalKind::quit()) - .expect("Failed to install signal handler") - .recv() - .await - }; - - tokio::select! { - () = ctrl_c => {}, - _ = terminate => {}, - _ = quit => {}, - } -} From b61b7a6cfc7c2fca8412885312192a411fb44102 Mon Sep 17 00:00:00 2001 From: tkatter Date: Fri, 12 Dec 2025 18:22:09 -0600 Subject: [PATCH 29/40] refactor: Fix stuff for windows build compat Fixed pretty much just to work. changelog: ignore --- sericom-core/src/cli.rs | 2 +- sericom-core/src/configs/defaults.rs | 7 ++++++- sericom-core/src/path_utils/macros.rs | 18 +++++++++++++----- sericom-core/src/screen/components/line.rs | 1 + sericom-core/src/screen/components/span.rs | 1 + sericom/src/main.rs | 3 ++- test-sericom/src/main.rs | 5 +++++ 7 files changed, 29 insertions(+), 8 deletions(-) diff --git a/sericom-core/src/cli.rs b/sericom-core/src/cli.rs index bd7f5f5..7e8bbb5 100644 --- a/sericom-core/src/cli.rs +++ b/sericom-core/src/cli.rs @@ -114,7 +114,7 @@ pub fn open_connection(baud: u32, port: &str) -> miette::Result { SerialPort::open(port, settings), format!("Failed to open port '{}'", port), help = format!( - "To see available ports, try `{}`.", + "Is the port already open?\nTo see available ports, try `{}`.", "list ports".bold().cyan() ) )?; diff --git a/sericom-core/src/configs/defaults.rs b/sericom-core/src/configs/defaults.rs index e1259c9..d7abab3 100644 --- a/sericom-core/src/configs/defaults.rs +++ b/sericom-core/src/configs/defaults.rs @@ -52,7 +52,12 @@ impl Default for Defaults { fn default_out_dir() -> PathBuf { use std::env::current_dir; - current_dir().unwrap_or_else(|_| PathBuf::from("./")) + current_dir().unwrap_or_else(|_| { + #[cfg(not(windows))] + return PathBuf::from("./"); + #[cfg(windows)] + return PathBuf::from(".\\"); + }) } fn validate_dir<'de, D>(deserializer: D) -> Result diff --git a/sericom-core/src/path_utils/macros.rs b/sericom-core/src/path_utils/macros.rs index 56a59dd..81d6351 100644 --- a/sericom-core/src/path_utils/macros.rs +++ b/sericom-core/src/path_utils/macros.rs @@ -182,11 +182,19 @@ macro_rules! compat_port_path { use chrono; let path_port = $crate::path_utils::get_compat_port_path($port)?; - PathBuf::from(format!( - "./{}-{}.txt", - path_port.display(), - chrono::Utc::now().format("%m%d%H%M"), - )) + if path_port.is_absolute() { + PathBuf::from(format!( + "{}-{}.txt", + path_port.display(), + chrono::Utc::now().format("%m%d%H%M"), + )) + } else { + PathBuf::from(format!( + "./{}-{}.txt", + path_port.display(), + chrono::Utc::now().format("%m%d%H%M"), + )) + } }}; } diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index f1ed050..c3613a9 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -183,6 +183,7 @@ impl IndexMut for Line { } impl crossterm::Command for Line { + fn execute_winapi(&self) -> Result<(), std::io::Error> { todo!() } fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { let mut spans = self.iter(); let Some(first) = spans.next() else { diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index ff8780c..bd941be 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -171,6 +171,7 @@ impl Span { } impl Command for Span { + fn execute_winapi(&self) -> Result<(), std::io::Error> { todo!() } fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { f.write_str(&String::from_iter(&self.cells)) } diff --git a/sericom/src/main.rs b/sericom/src/main.rs index 7375278..1f8b2e1 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -280,7 +280,8 @@ fn init_tracing( ) -> miette::Result> { use sericom_core::compat_port_path; - let path = compat_port_path!(out_dir); + let path = + compat_port_path!(get_config().unwrap().defaults.debug_dir.clone()); let file = std::fs::File::options() .write(true) .create(true) diff --git a/test-sericom/src/main.rs b/test-sericom/src/main.rs index acc3610..50d2f4f 100644 --- a/test-sericom/src/main.rs +++ b/test-sericom/src/main.rs @@ -5,10 +5,14 @@ use std::{ time::Duration, }; +#[cfg(unix)] mod pts; +#[cfg(unix)] use pts::get_pts_pair; fn main() -> std::io::Result<()> { + #[cfg(unix)] + { let (mut master, slave) = get_pts_pair()?; println!("Got slave: {slave}"); @@ -33,6 +37,7 @@ fn main() -> std::io::Result<()> { thread::sleep(Duration::from_secs(5)); seri_guard.wait(); + } Ok(()) } From 16c5e40899c477bf9b23f44cb83fe28171ff6410 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Fri, 12 Dec 2025 23:50:08 -0600 Subject: [PATCH 30/40] refactor(repl,tracing): Change CLI API and refactored init_tracing - Changed `exit-script` cli flag to `script` - Refactored `sericom::init_tracing()` so that debug events can be configured via `RUST_LOG` - Updated CLI API docs - Added `kill` subcommand to kill/close sessions - Added top-level `--color` flag for baseline of implementing colored/plain output depending on whether writing to stdout or pipe --- sericom-core/src/configs/mod.rs | 6 +- sericom-core/src/screen/tests/colors.rs | 2 +- sericom-core/src/screen/tests/mod.rs | 2 +- sericom/Cargo.toml | 2 +- sericom/src/main.rs | 150 ++++++++++++------------ 5 files changed, 83 insertions(+), 79 deletions(-) diff --git a/sericom-core/src/configs/mod.rs b/sericom-core/src/configs/mod.rs index deaca27..eecf9d2 100644 --- a/sericom-core/src/configs/mod.rs +++ b/sericom-core/src/configs/mod.rs @@ -33,6 +33,8 @@ pub static CONFIG: OnceLock> = OnceLock::new(); /// See [`Appearance`] and [`Defaults`] #[derive(Default, Debug, Deserialize, PartialEq, Eq)] pub struct Config { + // Global fields for cli behaviors?? + // color: ["always", "never", "auto"] #[serde(default)] pub appearance: Appearance, #[serde(default)] @@ -47,7 +49,7 @@ impl Config { if let Some(dir) = overrides.out_dir { self.defaults.out_dir = dir; } - if let Some(script) = overrides.exit_script { + if let Some(script) = overrides.script { self.defaults.exit_script = Some(script); } } @@ -120,7 +122,7 @@ pub struct ConfigOverride { /// Overrides [`Defaults::out_dir`] pub out_dir: Option, /// Overrides [`Defaults::exit_script`] - pub exit_script: Option, + pub script: Option, } fn get_conf_dir() -> std::path::PathBuf { diff --git a/sericom-core/src/screen/tests/colors.rs b/sericom-core/src/screen/tests/colors.rs index 7ca8701..d0d9d90 100644 --- a/sericom-core/src/screen/tests/colors.rs +++ b/sericom-core/src/screen/tests/colors.rs @@ -5,7 +5,7 @@ use crossterm::style::{Attribute, Attributes, Color}; const CONF_OR: ConfigOverride = ConfigOverride { color: None, out_dir: None, - exit_script: None, + script: None, }; struct Case<'a> { diff --git a/sericom-core/src/screen/tests/mod.rs b/sericom-core/src/screen/tests/mod.rs index 2dc7e22..1bb9e02 100644 --- a/sericom-core/src/screen/tests/mod.rs +++ b/sericom-core/src/screen/tests/mod.rs @@ -13,7 +13,7 @@ pub use std::collections::VecDeque; pub const CONFIG_OVERRIDE: ConfigOverride = ConfigOverride { color: None, out_dir: None, - exit_script: None, + script: None, }; pub const TERMINAL_SIZE: (u16, u16) = (80, 24); diff --git a/sericom/Cargo.toml b/sericom/Cargo.toml index 931ae68..ad432c1 100644 --- a/sericom/Cargo.toml +++ b/sericom/Cargo.toml @@ -32,7 +32,7 @@ clap = { version = "4.5.50", features = ["derive"] } rustyline = { version = "17.0.1", features = ["derive"] } sericom-core = { version = "0.7.0", path = "../sericom-core" } tracing-appender = "0.2" -tracing-subscriber = "0.3.20" +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } chrono.workspace = true tokio.workspace = true serial2-tokio.workspace = true diff --git a/sericom/src/main.rs b/sericom/src/main.rs index 1f8b2e1..310f3b3 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -28,6 +28,14 @@ const RESET: &str = "\x1b\x5b0m"; struct Cli { #[command(subcommand)] command: Commands, + #[arg( + long = "color", + value_parser = ["auto", "always", "never"], + default_value = "auto", + help = "Specify WHEN to colorize output", + require_equals = true + )] + color: String, } #[allow(clippy::enum_variant_names)] @@ -36,26 +44,28 @@ enum Commands { /// Connect to a serial port #[command(alias = "c", alias = "con")] Connect { - /// The path to a serial port. + /// The path to a serial port /// /// For Linux/MacOS something like `/dev/ttyUSB0`, Windows `COM1`. port: String, - /// Baud rate for the serial connection. + /// Baud rate for the serial connection #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] baud: u32, - #[clap(flatten)] - config_override: ConfigOverrides, - /// Path to a file for the output. + /// Path to a file to write the session's output #[arg(short, long)] file: Option>, /// Start the session in the background (headless) #[arg(long)] bg: bool, - /// Write debug output - #[arg(short, long)] - debug: bool, + #[clap(flatten)] + config_override: ConfigOverrides, + }, + /// Close a session + #[command(alias = "k")] + Kill { + session: Vec, }, - /// List helpful information like valid baud rates, available serial ports, and sessions. + /// List helpful information like valid baud rates, available serial ports, sessions, etc. #[command(alias = "ls", alias = "l")] List { #[command(subcommand)] @@ -63,29 +73,24 @@ enum Commands { }, // TODO: Use this to print user settings like `git config --list` // Set { - // Stuff to set settings + // /* Stuff to set settings */ // } - // Settings { - // #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] - // baud: u32, - // /// Path to the port to open - // #[arg(short, long)] - // port: String, - // }, } #[derive(Subcommand)] enum ListCmds { - /// List available serial ports. + /// List the current sessions/connections + #[command(alias = "s", alias = "sess")] + Sessions, + /// List available serial ports #[command(alias = "p")] Ports, - /// List valid baud rates. + /// List valid baud rates #[command(alias = "b")] Bauds, - #[command(alias = "s", alias = "sess")] - Sessions, - #[command(alias = "set")] - Settings, + /// List the current configuration + #[command(alias = "conf")] + Config, } #[derive(Parser, Debug)] @@ -98,9 +103,9 @@ struct ConfigOverrides { /// Alternatively could simply use the absolute path #[arg(short, long, requires_all = &["port", "file"], value_parser = validate_dir)] out_dir: Option, - /// Override the `exit-script` that's run after writing to a file + /// Override the `script` that's run after writing to a file #[arg(long, requires_all = &["port", "file"], value_parser = is_script)] - exit_script: Option, + script: Option, } impl From for sericom_core::configs::ConfigOverride { @@ -108,25 +113,18 @@ impl From for sericom_core::configs::ConfigOverride { sericom_core::configs::ConfigOverride { color: overrides.color, out_dir: overrides.out_dir, - exit_script: overrides.exit_script, + script: overrides.script, } } } -const CONFIG_OVERRIDE: sericom_core::configs::ConfigOverride = - sericom_core::configs::ConfigOverride { - color: None, - out_dir: None, - exit_script: None, - }; - #[tokio::main] async fn main() -> miette::Result<()> { initialize_config(None)?; let _trace_guard: Option = { let config = get_config().unwrap(); - let out_dir = config.defaults.debug_dir.as_path(); - init_tracing(out_dir, false)? // TODO: GET DEBUG CLI FLAG HERE + let dbg_dir = config.defaults.debug_dir.as_path(); + init_tracing(dbg_dir)? }; run_repl().await @@ -146,11 +144,9 @@ async fn run_repl() -> miette::Result<()> { if rl.load_history(&history_path).is_err() { println!("{DIM}No previous history{RESET}"); } - println!("Welcome to the sericom, type 'exit' to quit."); let mut manager = sericom_core::session::SessionManager::new(); - loop { let readline = rl.readline(">> "); match readline { @@ -197,7 +193,6 @@ async fn handle_cmds( cmd: Commands, manager: &mut sericom_core::session::SessionManager, ) -> miette::Result<()> { - let mut stdout = std::io::stdout(); match cmd { Commands::Connect { port, @@ -205,12 +200,8 @@ async fn handle_cmds( config_override, file, bg, - debug, } => { manager.spawn(&port, baud)?; - // let connection = open_connection(baud, &port)?; - // let overrides: sericom_core::configs::ConfigOverride = config_override.into(); - // // if let Some(Some(path)) = &file // && path.is_dir() // { @@ -231,6 +222,13 @@ async fn handle_cmds( // interactive_session(connection, file, &port).await?; Ok(()) } + Commands::Kill { session } => { + for id in session { + manager.kill(id).await; + tracing::debug!(target: "repl::kill", "session {id} closed"); + } + Ok(()) + } Commands::List { cmd } => match cmd { ListCmds::Ports => list_serial_ports(), ListCmds::Bauds => { @@ -241,12 +239,11 @@ async fn handle_cmds( Ok(()) } ListCmds::Sessions => { - manager.list(&mut stdout); + manager.list(&mut std::io::stdout()); Ok(()) } - ListCmds::Settings => todo!(), + ListCmds::Config => todo!(), }, - // Commands::Settings { baud, port } => get_settings(baud, &port), } } @@ -268,20 +265,20 @@ fn handle_help(line: &str) { { sub_cmd.print_help().ok(); println!(); - return; } - - println!("{RED}No help found for '{BOLD}{line}{UNBOLD}'.{RESET}"); } fn init_tracing( - out_dir: &Path, - _debug: bool, + dbg_dir: &Path, ) -> miette::Result> { use sericom_core::compat_port_path; + use tracing::{Level, level_filters::LevelFilter}; + use tracing_subscriber::EnvFilter; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + use tracing_subscriber::{filter, fmt}; - let path = - compat_port_path!(get_config().unwrap().defaults.debug_dir.clone()); + let path = compat_port_path!(dbg_dir); let file = std::fs::File::options() .write(true) .create(true) @@ -291,28 +288,33 @@ fn init_tracing( .wrap_err_with(|| format!("Failed to create '{}'", path.display()))?; let (non_blocking, guard) = tracing_appender::non_blocking(file); - let subscriber = tracing_subscriber::fmt() - .with_max_level({ - #[cfg(debug_assertions)] - { - tracing::Level::TRACE - } - #[cfg(not(debug_assertions))] - { - if _debug { - tracing::Level::DEBUG - } else { - tracing::Level::INFO - } - } - }) - .with_writer(non_blocking) - .with_line_number(false) - .with_target(true) - .finish(); - tracing::subscriber::set_global_default(subscriber) - .into_diagnostic() - .wrap_err("Failed to set subscriber")?; + tracing_subscriber::registry() + .with( + fmt::layer() + .with_writer(non_blocking) + .with_line_number(false) + .with_target(true), + ) + .with( + filter::Targets::new() + .with_target("sericom", Level::TRACE) + .with_target("sericom_core", Level::TRACE) + .with_target("session", Level::TRACE) + .with_target("repl", Level::TRACE) + .with_default(Level::ERROR), + ) + .with( + EnvFilter::builder() + .with_default_directive( + #[cfg(debug_assertions)] + LevelFilter::TRACE.into(), + #[cfg(not(debug_assertions))] + LevelFilter::INFO.into(), + ) + .from_env_lossy(), + ) + .init(); + Ok(Some(guard)) } From be3d77ed250694992ea9b370004d224327c12f36 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Fri, 12 Dec 2025 23:54:45 -0600 Subject: [PATCH 31/40] refactor: `cfg!(windows)` for `crossterm::Command::execute_winapi()` --- sericom-core/src/screen/components/line.rs | 6 +++++- sericom-core/src/screen/components/span.rs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index c3613a9..bd0a1e2 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -183,7 +183,11 @@ impl IndexMut for Line { } impl crossterm::Command for Line { - fn execute_winapi(&self) -> Result<(), std::io::Error> { todo!() } + #[cfg(windows)] + fn execute_winapi(&self) -> Result<(), std::io::Error> { + todo!() + } + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { let mut spans = self.iter(); let Some(first) = spans.next() else { diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index bd941be..8ef8713 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -171,7 +171,11 @@ impl Span { } impl Command for Span { - fn execute_winapi(&self) -> Result<(), std::io::Error> { todo!() } + #[cfg(windows)] + fn execute_winapi(&self) -> Result<(), std::io::Error> { + todo!() + } + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { f.write_str(&String::from_iter(&self.cells)) } From e072b6795af6ac2584fb70b7b6d712861208ef9b Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Fri, 12 Dec 2025 23:57:16 -0600 Subject: [PATCH 32/40] refactor: Added tracing events and fixed hanging bug on shutdown changelog: ignore --- sericom-core/src/serial_actor/mod.rs | 3 +- sericom-core/src/session/handle.rs | 46 +++++----------------------- sericom-core/src/session/mod.rs | 15 ++++----- test-sericom/src/main.rs | 38 +++++++++++------------ 4 files changed, 37 insertions(+), 65 deletions(-) diff --git a/sericom-core/src/serial_actor/mod.rs b/sericom-core/src/serial_actor/mod.rs index 5f3405b..5d70cbb 100644 --- a/sericom-core/src/serial_actor/mod.rs +++ b/sericom-core/src/serial_actor/mod.rs @@ -87,6 +87,7 @@ impl SerialActor { Some(SerialMessage::Shutdown) => { tracing::debug!(target: "session::actor", "recieved shutdown command"); self.broadcast_channel.send(SerialEvent::ConnectionClosed).ok(); + break; } Some(SerialMessage::SendBreak) => { tracing::debug!(target: "session::actor", "sending break signal"); @@ -99,7 +100,7 @@ impl SerialActor { read_result = self.connection.read(&mut buffer) => { match read_result { Ok(0) => { - tracing::debug!(target: "session::actor", "no bytes read - connection closed"); + tracing::debug!(target: "session::actor", "connection closed: no bytes read"); self.broadcast_channel.send(SerialEvent::ConnectionClosed).ok(); break; } diff --git a/sericom-core/src/session/handle.rs b/sericom-core/src/session/handle.rs index 420a214..24a9ece 100644 --- a/sericom-core/src/session/handle.rs +++ b/sericom-core/src/session/handle.rs @@ -57,12 +57,9 @@ impl SessionHandle { let buffer = Arc::new(tokio::sync::RwLock::new(sb)); let actor = SerialActor::new(connection, rx, events_tx); - // Not storing the span in SessionHandle because I'm not sure if I'll - // ever need to emit events outside of the SessionHandle in the same span. - // If I do I could just re-create the span from SessionMeta. let span = tracing::info_span!("session", port = %meta.port, baud = %meta.baud); let _enter = span.enter(); - tracing::info!(target: "session", "Opened connection"); + tracing::info!(target: "session::handle::spawn", "Opened connection"); let mut tasks = JoinSet::new(); tasks.spawn(actor.run().instrument(span.clone())); @@ -77,8 +74,14 @@ impl SessionHandle { } pub async fn shutdown(self) { - let _ = self.tx.send(SerialMessage::Shutdown).await; + if let Err(e) = self.tx.send(SerialMessage::Shutdown).await { + tracing::debug!(target: "session::handle::shutdown", %e, "error sending shutdown to actor"); + let mut tasks = self.tasks; + tasks.abort_all(); + return; + } self.tasks.join_all().await; + tracing::trace!(target: "session::handle::shutdown", "all tasks finished, shutting down"); } } @@ -106,36 +109,3 @@ async fn parse_task( } } } - -/* -#[test] -fn sizes() { - eprintln!( - "size of handle: {}\n - align of handle: {}\n - size of buffer: {}\n - align of buffer: {}\n - size of tx: {}\n - align of tx: {}\n - size of events: {}\n - align of events: {}\n - size of tasks: {}\n - align of tasks: {}\n - size of Span: {}\n - align of Span: {}", - size_of::(), - align_of::(), - size_of::>>(), - align_of::>>(), - size_of::>(), - align_of::>(), - size_of::>(), - align_of::>(), - size_of::>(), - align_of::>(), - size_of::(), - align_of::(), - ); - assert!(1 > 2); -} -*/ diff --git a/sericom-core/src/session/mod.rs b/sericom-core/src/session/mod.rs index 471f21b..bb9808e 100644 --- a/sericom-core/src/session/mod.rs +++ b/sericom-core/src/session/mod.rs @@ -1,6 +1,5 @@ mod handle; pub use handle::SessionHandle; -use miette::IntoDiagnostic; pub type SessionID = u8; @@ -12,11 +11,6 @@ pub struct SessionManager { } /// Includes the baud rate and name of the port/connection. -/// -/// TODO: Possible include [`Appearance`] here too so that each -/// session's default colors can be set by CLI flags? -/// -/// [`Appearance`]: crate::configs::Appearance #[derive(Debug, Clone)] pub struct SessionMeta { pub(crate) baud: u32, @@ -63,6 +57,7 @@ impl SessionManager { let id = self.metas.len(); if !id < u8::MAX as usize { + tracing::warn!(target: "session::spawn", %id, "max sessions reached"); return Err(()).map_err(|_| miette::miette!("Max sessions reached"))?; // TODO: HANDLE STDOUT ERROR PROPAGATING } @@ -88,11 +83,13 @@ impl SessionManager { } if self.handles.get(idx).is_none() { + tracing::debug!(target: "session::kill", "session {id} does not exist"); return; } self.metas.swap_remove(idx); self.handles.swap_remove(idx).shutdown().await; + tracing::trace!(target: "session::kill", "session {idx} killed"); // NOTE: // Shouldn't need to adjust active idx because when the user is in the @@ -145,7 +142,11 @@ impl SessionManager { for session in self.handles { idx += 1; session.shutdown().await; - tracing::info!(target: "session", port = %self.metas[idx as usize].port, "Shut down session {idx}"); + tracing::info!( + target: "session", + port = %self.metas[(idx as usize).saturating_sub(1)].port, + "shutdown session: '{idx}'" + ); } } } diff --git a/test-sericom/src/main.rs b/test-sericom/src/main.rs index 50d2f4f..612e0fb 100644 --- a/test-sericom/src/main.rs +++ b/test-sericom/src/main.rs @@ -13,30 +13,30 @@ use pts::get_pts_pair; fn main() -> std::io::Result<()> { #[cfg(unix)] { - let (mut master, slave) = get_pts_pair()?; - println!("Got slave: {slave}"); + let (mut master, slave) = get_pts_pair()?; + println!("Got slave: {slave}"); - let mut seri_guard = ChildGuard { - child: Some( - Command::new("/home/thomas/.cargo/bin/sericom") - .arg(slave) - .spawn() - .expect("Failed to start 'sericom'"), - ), - }; + let mut seri_guard = ChildGuard { + child: Some( + Command::new("/home/thomas/.cargo/bin/sericom") + .arg(slave) + .spawn() + .expect("Failed to start 'sericom'"), + ), + }; - master.write_all(b"Hello from simulated serial!\r\n")?; - master.flush()?; + master.write_all(b"Hello from simulated serial!\r\n")?; + master.flush()?; - thread::sleep(Duration::from_secs(2)); + thread::sleep(Duration::from_secs(2)); - master.write_all(b"\x1B[2J")?; - master.write_all(b"\x1B[H")?; - master.write_all(b"Ready>\r\n")?; - master.flush()?; + master.write_all(b"\x1B[2J")?; + master.write_all(b"\x1B[H")?; + master.write_all(b"Ready>\r\n")?; + master.flush()?; - thread::sleep(Duration::from_secs(5)); - seri_guard.wait(); + thread::sleep(Duration::from_secs(5)); + seri_guard.wait(); } Ok(()) } From 7ae6c8c8ec58a3864c2af31b123254742cc09356 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Sat, 13 Dec 2025 00:24:42 -0600 Subject: [PATCH 33/40] refactor(repl): Major transformation of CLI API to a REPL structure Cleaned up a lot of code in `sericom-core` as well. --- sericom-core/src/cli.rs | 67 +++--------- sericom-core/src/path_utils/macros.rs | 23 ++-- sericom/Cargo.toml | 2 +- sericom/src/main.rs | 151 +++++++++++++++----------- 4 files changed, 111 insertions(+), 132 deletions(-) diff --git a/sericom-core/src/cli.rs b/sericom-core/src/cli.rs index 7e8bbb5..69c1ced 100644 --- a/sericom-core/src/cli.rs +++ b/sericom-core/src/cli.rs @@ -113,10 +113,7 @@ pub fn open_connection(baud: u32, port: &str) -> miette::Result { let con = map_miette!( SerialPort::open(port, settings), format!("Failed to open port '{}'", port), - help = format!( - "Is the port already open?\nTo see available ports, try `{}`.", - "list ports".bold().cyan() - ) + help = "Is the port already open?\nTo see available ports, try `list ports`." )?; Ok(con) } @@ -125,7 +122,6 @@ pub fn open_connection(baud: u32, port: &str) -> miette::Result { #[allow(clippy::many_single_char_names)] pub fn get_settings(baud: u32, port: &str) -> miette::Result<()> { // https://www.contec.com/support/basic-knowledge/daq-control/serial-communicatin/ - let mut stdout = io::stdout(); let con = open_connection(baud, port)?; let settings = map_miette!( con.get_configuration(), @@ -169,33 +165,15 @@ pub fn get_settings(baud: u32, port: &str) -> miette::Result<()> { format!("Failed to read CD for port '{}'", port) )?; - write!(stdout, "Baud rate: {b}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Char size: {c}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Stop bits: {s}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Parity mechanism: {p}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Flow control: {f}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Clear To Send line: {cts}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Data Set Ready line: {dsr}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Ring Indicator line: {ri}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; - write!(stdout, "Carrier Detect line: {cd}\r\n") - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; + println!("Baud rate: {b}"); + println!("Char size: {c}"); + println!("Stop bits: {s}"); + println!("Parity mechanism: {p}"); + println!("Flow control: {f}"); + println!("Clear To Send line: {cts}"); + println!("Data Set Ready line: {dsr}"); + println!("Ring Indicator line: {ri}"); + println!("Carrier Detect line: {cd}"); Ok(()) } @@ -205,21 +183,13 @@ pub fn get_settings(baud: u32, port: &str) -> miette::Result<()> { /// Ultimately a wrapper around [`SerialPort::available_ports()`] and may error /// if it is called on an unsupported platform as per [`SerialPort::available_ports()`]s docs pub fn list_serial_ports() -> miette::Result<()> { - let mut stdout = io::stdout(); let ports = map_miette!( SerialPort::available_ports(), "Could not list available ports." )?; - for path in ports { - let Some(path) = path.to_str() else { - continue; - }; - let line = [path, "\r\n"].concat(); - stdout - .write(line.as_bytes()) - .into_diagnostic() - .wrap_err("Failed to write to stdout.".red())?; + for path in ports { + println!("{}", path.display()); } Ok(()) } @@ -233,11 +203,7 @@ pub fn valid_baud_rate(s: &str) -> Result { if serial2_tokio::COMMON_BAUD_RATES.contains(&baud) { Ok(baud) } else { - Err(format!( - "'{}' is not a valid baud rate; valid baud rates include {:?}", - baud, - serial2_tokio::COMMON_BAUD_RATES - )) + Err(format!("'{baud}' is not a valid baud rate")) } } @@ -270,9 +236,6 @@ fn ensure_terminal_cleanup(mut stdout: io::Stdout) { fn run_file_exit_script(file_path: PathBuf) { let config = crate::configs::get_config().unwrap(); //TODO: HANDLE - let span = tracing::span!(Level::DEBUG, "Exit script"); - let _enter = span.enter(); - let Some(script_path) = config.defaults.exit_script.as_ref() else { return; }; @@ -281,12 +244,12 @@ fn run_file_exit_script(file_path: PathBuf) { .expect("All error conditions have been checked"); let cmd = create_platform_cmd(script_path, full_file_path); if let Ok(output) = cmd { - let msg = format!( + tracing::debug!( + target: "exit_script", "stdout: {}, stderr: {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - tracing::debug!(msg); } } diff --git a/sericom-core/src/path_utils/macros.rs b/sericom-core/src/path_utils/macros.rs index 81d6351..88f134c 100644 --- a/sericom-core/src/path_utils/macros.rs +++ b/sericom-core/src/path_utils/macros.rs @@ -1,5 +1,7 @@ //! This module holds helper macros for dealing with paths +/// Recursively create a directory. +/// /// Takes a [`&Path`][std::path::Path] and first checks whether it exists or if it is a /// directory. If it doesn't exist or is not a directory, it will create /// the directory recursively; creating the necessary parent directories. @@ -32,12 +34,10 @@ macro_rules! create_recursive { /// Used to add a `.map_err()` to function calls that return a `Result` /// to provide better context for the error and print it nicely to stdout. /// -/// Takes 2 arguements and optionally a third and fourth: +/// Takes 2 arguements and optionally a third for an additional help message. /// - The first argument is the expression or function call that would return a `Result` /// - The second argument is context that better describes the returned error -/// - The optional third argument is the 'USAGE: sericom ...' that would typically be printed by `clap` -/// for the respective command -/// - The optional fourth argument is an additional "help:" message +/// - The optional third argument is an additional "help:" message /// /// ## Example /// ``` @@ -50,14 +50,7 @@ macro_rules! create_recursive { /// let x = map_miette!( /// SerialPort::open(port, baud), /// format!("Failed to open port '{}'", port), -/// format!("{} {} [OPTIONS] [PORT] [COMMAND]", -/// "USAGE:".bold().underlined(), -/// "sericom".bold() -/// ), -/// help = format!( -/// "To see available ports, try `{}`.", -/// "sericom list-ports".bold().cyan() -/// ) +/// help = "To see available ports, try `list ports`." /// )?; /// Ok(()) /// } @@ -69,21 +62,19 @@ macro_rules! map_miette { // Default "help" message ($expr:expr, $wrap_msg:expr) => { $expr.map_err(|e| { - use crossterm::style::Stylize; miette::miette!(help = "For more information, try `help [COMMAND]`.", "{e}") - .wrap_err(format!("{}", $wrap_msg).red()) + .wrap_err($wrap_msg) }) }; // Additional "help" message ($expr:expr, $wrap_msg:expr, help = $add_help:expr) => { $expr.map_err(|e| { - use crossterm::style::Stylize; miette::miette!( help = format!("{}\nFor more information, try `help [COMMAND]`.", $add_help), "{e}" ) - .wrap_err(format!("{}", $wrap_msg).red()) + .wrap_err($wrap_msg) }) }; } diff --git a/sericom/Cargo.toml b/sericom/Cargo.toml index ad432c1..00f235e 100644 --- a/sericom/Cargo.toml +++ b/sericom/Cargo.toml @@ -28,7 +28,7 @@ eula = false rustflags = ["-C", "target-feature=+crt-static"] [dependencies] -clap = { version = "4.5.50", features = ["derive"] } +clap = { version = "4.5.50", features = ["derive", "help"] } rustyline = { version = "17.0.1", features = ["derive"] } sericom-core = { version = "0.7.0", path = "../sericom-core" } tracing-appender = "0.2" diff --git a/sericom/src/main.rs b/sericom/src/main.rs index 310f3b3..5c07976 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -8,7 +8,7 @@ //! configuration, resetting, getting statistics, etc. use clap::{CommandFactory, Parser, Subcommand}; -use miette::{Context, IntoDiagnostic}; +use miette::{Context as _, IntoDiagnostic}; use sericom_core::{ cli::{color_parser, list_serial_ports, valid_baud_rate}, configs::{get_config, initialize_config}, @@ -17,32 +17,45 @@ use sericom_core::{ use std::path::{Path, PathBuf}; const RED: &str = "\x1b\x5b31m"; +const CYAN: &str = "\x1b\x5b36m"; const BOLD: &str = "\x1b\x5b1m"; -const UNBOLD: &str = "\x1b\x5b22m"; +// const UNBOLD: &str = "\x1b\x5b22m"; const DIM: &str = "\x1b\x5b2m"; const RESET: &str = "\x1b\x5b0m"; +const PARSER_TEMPLATE: &str = "\ + {all-args}{after-help}\ +"; +const SUBCOMMAND_TEMPLATE: &str = "\ + {usage-heading}\n {usage}\n\ + \n\ + {all-args}{after-help}\ +"; #[derive(Parser)] -#[command(name = "sericom", version, about, long_about = None)] -#[command(propagate_version = true)] -struct Cli { +#[command( + version, + about, + long_about = None, + infer_subcommands = true, + arg_required_else_help = true, + help_template = PARSER_TEMPLATE, + disable_colored_help = false, + after_help = "For command specific help use `help [COMMAND]`.\n\ + To get help in the middle of a command you can use `?`.\n\ + For example: `list ?` will print the help text for the `list` command. + ", + multicall = true +)] +struct Repl { #[command(subcommand)] command: Commands, - #[arg( - long = "color", - value_parser = ["auto", "always", "never"], - default_value = "auto", - help = "Specify WHEN to colorize output", - require_equals = true - )] - color: String, } #[allow(clippy::enum_variant_names)] #[derive(Subcommand)] enum Commands { /// Connect to a serial port - #[command(alias = "c", alias = "con")] + #[command(visible_aliases = ["c", "con"], long_about = None, help_template = SUBCOMMAND_TEMPLATE)] Connect { /// The path to a serial port /// @@ -61,16 +74,22 @@ enum Commands { config_override: ConfigOverrides, }, /// Close a session - #[command(alias = "k")] + #[command(visible_alias = "k", help_template = SUBCOMMAND_TEMPLATE)] Kill { session: Vec, }, - /// List helpful information like valid baud rates, available serial ports, sessions, etc. - #[command(alias = "ls", alias = "l")] + /// List helpful information + #[command(visible_aliases = ["ls","l"], help_template = SUBCOMMAND_TEMPLATE)] List { #[command(subcommand)] cmd: ListCmds, }, + /// Exit the program + #[command(visible_aliases = ["e", "q","quit"])] + Exit, + /// Print the version of sericom + #[command(visible_short_flag_alias = 'V', visible_long_flag_alias = "version")] + Version, // TODO: Use this to print user settings like `git config --list` // Set { // /* Stuff to set settings */ @@ -80,16 +99,16 @@ enum Commands { #[derive(Subcommand)] enum ListCmds { /// List the current sessions/connections - #[command(alias = "s", alias = "sess")] + #[command(visible_alias = "s")] Sessions, /// List available serial ports - #[command(alias = "p")] + #[command(visible_alias = "p")] Ports, /// List valid baud rates - #[command(alias = "b")] + #[command(visible_alias = "b")] Bauds, /// List the current configuration - #[command(alias = "conf")] + #[command(visible_alias = "conf")] Config, } @@ -133,6 +152,8 @@ async fn main() -> miette::Result<()> { async fn run_repl() -> miette::Result<()> { let conf = rustyline::Config::builder() .history_ignore_space(true) + .completion_show_all_if_ambiguous(true) + .completion_type(rustyline::CompletionType::Circular) .build(); let mut rl = rustyline::DefaultEditor::with_config(conf) .into_diagnostic() @@ -158,22 +179,27 @@ async fn run_repl() -> miette::Result<()> { rl.add_history_entry(line).ok(); - if line == "exit" || line == "q" || line == "quit" { - break; - } - if line.contains('?') { handle_help(line); continue; } - let args = format!("sericom {}", line); - let cli = Cli::try_parse_from(args.split_whitespace()); - match cli { - Ok(cli) => handle_cmds(cli.command, &mut manager).await?, - Err(e) => { - print!("{e}"); - } + match Repl::try_parse_from(line.split_whitespace().collect::>()) { + Ok(cli) => match handle_cmds(cli.command, &mut manager).await { + Ok(true) => break, + Ok(false) => {} + Err(e) => { + println!("{RED}\u{1F5F4} {e}{RESET}"); + for e in e.chain().skip(1) { + println!(" {RED}\u{2937}{RESET} {e}"); + } + + if let Some(help) = e.help() { + println!("\n{CYAN}{BOLD}help:{RESET} {help}\n"); + } + } + }, + Err(e) => e.print().expect("error printing clap error"), } } Err(rustyline::error::ReadlineError::Interrupted) => continue, @@ -192,7 +218,7 @@ async fn run_repl() -> miette::Result<()> { async fn handle_cmds( cmd: Commands, manager: &mut sericom_core::session::SessionManager, -) -> miette::Result<()> { +) -> miette::Result { match cmd { Commands::Connect { port, @@ -201,58 +227,57 @@ async fn handle_cmds( file, bg, } => { - manager.spawn(&port, baud)?; - // if let Some(Some(path)) = &file - // && path.is_dir() - // { - // return Err(miette::miette!( - // "Could not create file at: '{}' because it is a directory.", - // path.display() - // )); - // } - // initialize_config(overrides)?; - // // Need to hold the guard in `main`'s scope - // let _guard: Option = if debug { - // let config = get_config(); - // let out_dir = config.defaults.debug_dir.as_path(); - // init_tracing(out_dir)? - // } else { - // None - // }; - // interactive_session(connection, file, &port).await?; - Ok(()) + if bg { + return manager.spawn(&port, baud).map(|_| false); + } + + let id = manager.spawn(&port, baud)?; + if let Some(Some(path)) = &file + && path.is_dir() + { + return Err(miette::miette!( + "Could not create file at: '{}' because it is a directory.", + path.display() + )); + } + + Ok(false) } Commands::Kill { session } => { for id in session { manager.kill(id).await; tracing::debug!(target: "repl::kill", "session {id} closed"); } - Ok(()) + Ok(false) } Commands::List { cmd } => match cmd { - ListCmds::Ports => list_serial_ports(), + ListCmds::Ports => list_serial_ports().map(|_| false), ListCmds::Bauds => { println!("Valid baud rates:"); for baud in serial2_tokio::COMMON_BAUD_RATES { println!("{baud}"); } - Ok(()) + Ok(false) } ListCmds::Sessions => { manager.list(&mut std::io::stdout()); - Ok(()) + Ok(false) } ListCmds::Config => todo!(), }, + Commands::Exit => Ok(true), + Commands::Version => { + println!("{}", Repl::command().render_long_version()); + Ok(false) + } } } fn handle_help(line: &str) { - let mut cmd = Cli::command(); + let mut cmd = Repl::command(); let tokens: Vec<&str> = line.split_whitespace().collect(); - if tokens.is_empty() || tokens == ["?"] { - cmd.print_help().unwrap(); - println!(); + if tokens == ["?"] || tokens.is_empty() { + cmd.print_help().expect("help won't err"); return; } @@ -263,8 +288,7 @@ fn handle_help(line: &str) { .find(|s| s.get_name() == *sub) .cloned() { - sub_cmd.print_help().ok(); - println!(); + sub_cmd.print_help().expect("help won't err"); } } @@ -302,6 +326,7 @@ fn init_tracing( .with_target("sericom_core", Level::TRACE) .with_target("session", Level::TRACE) .with_target("repl", Level::TRACE) + .with_target("exit_script", Level::TRACE) .with_default(Level::ERROR), ) .with( From 9a995a12f254dc1867127c3a31532df6fae410dc Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Sat, 13 Dec 2025 04:10:51 -0600 Subject: [PATCH 34/40] refactor(file,repl): Implemented writing session output to file Lots of changes all around the codebase, would have been nice to make smaller commits but undoing-rewriting led me on a wild goose chase all over to polish a few things up and refactor to fit this new system. Still mutalating 'sericom/src/main.rs' as I play with the REPL and notice things, or as needed for interop with sericom_core. Tracing/debugging/logging is still a WIP, trying to find a flow/system that I like, but a lot of changes in files from this commit were playing with the tracing/debugging. *File-writing:* The file-writing task seems to be where it was before this refactor. A nice improvement that came along with this refactor is that it will now write lines that have already been parsed - directly from the ScreenBuffer. This eliminates just writing whatever the session received (escape sequence galore). *Testing:* Wired up the testing harness to simulate a serial connection, important to note that this only works on Linux and don't plan on implementing it for Windows because I hate Windows. See 'sericom-core/src/session/handle.rs' for how to use the harness. --- Cargo.lock | 9 +- justfile | 4 +- sericom-core/Cargo.toml | 3 +- sericom-core/src/cli.rs | 136 ++------ sericom-core/src/configs/mod.rs | 32 +- sericom-core/src/lib.rs | 1 + sericom-core/src/path_utils/validators.rs | 6 +- sericom-core/src/screen/buffer.rs | 41 ++- sericom-core/src/screen/components/line.rs | 11 + sericom-core/src/screen/components/span.rs | 33 +- sericom-core/src/screen/driver.rs | 20 +- sericom-core/src/screen/mod.rs | 4 +- sericom-core/src/screen/process/colors.rs | 1 + sericom-core/src/screen/render.rs | 34 -- sericom-core/src/screen/tests/colors.rs | 38 ++- sericom-core/src/screen/tests/components.rs | 23 +- sericom-core/src/screen/tests/cursor.rs | 6 +- sericom-core/src/screen/tests/escape.rs | 21 +- sericom-core/src/screen/tests/mod.rs | 20 +- sericom-core/src/serial_actor/mod.rs | 31 +- sericom-core/src/serial_actor/tasks.rs | 323 +------------------ sericom-core/src/session/handle.rs | 327 +++++++++++++++++--- sericom-core/src/session/mod.rs | 62 ++-- sericom/src/main.rs | 49 +-- test-sericom/Cargo.toml | 2 + test-sericom/src/lib.rs | 122 ++++++++ test-sericom/src/main.rs | 74 ----- test-sericom/src/pts.rs | 47 --- 28 files changed, 716 insertions(+), 764 deletions(-) create mode 100644 test-sericom/src/lib.rs delete mode 100644 test-sericom/src/main.rs delete mode 100644 test-sericom/src/pts.rs diff --git a/Cargo.lock b/Cargo.lock index 4732370..09f410a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,9 +490,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "linux-raw-sys" @@ -903,6 +903,7 @@ dependencies = [ "serde", "serial2-tokio", "test-log", + "test-sericom", "thiserror 2.0.17", "tokio", "toml", @@ -1043,6 +1044,10 @@ dependencies = [ [[package]] name = "test-sericom" version = "0.1.0" +dependencies = [ + "libc", + "tracing", +] [[package]] name = "textwrap" diff --git a/justfile b/justfile index d1c4483..83769d5 100644 --- a/justfile +++ b/justfile @@ -51,5 +51,5 @@ run-trace: test tests=default-tests: cargo test {{tests}} --no-fail-fast --lib -test-trace target=default-trace tests=default-tests: - RUST_LOG='{{target}}=trace' cargo test {{tests}} --no-fail-fast --lib -- --nocapture +test-trace tests=default-tests: + RUST_LOG='sericom_core::screen::buffer=debug,sericom_core=trace' cargo test {{tests}} --no-fail-fast --lib -- --nocapture diff --git a/sericom-core/Cargo.toml b/sericom-core/Cargo.toml index 0c19f54..3425e2d 100644 --- a/sericom-core/Cargo.toml +++ b/sericom-core/Cargo.toml @@ -17,10 +17,11 @@ crossterm.workspace = true miette.workspace = true serial2-tokio.workspace = true tokio.workspace = true -tracing.workspace = true +tracing = { workspace = true, features = ["attributes"] } [dev-dependencies] test-log = {version = "0.2.18", default-features = false, features = ["trace", "color"]} +test-sericom = { version = "0.1.0", path = "../test-sericom" } [lints.clippy] pedantic = "warn" diff --git a/sericom-core/src/cli.rs b/sericom-core/src/cli.rs index 69c1ced..a88f167 100644 --- a/sericom-core/src/cli.rs +++ b/sericom-core/src/cli.rs @@ -1,106 +1,19 @@ //! This module holds the functions that are called from `sericom` when receiving //! CLI commands/arguments. -use crate::{ - compat_port_path, - configs::get_config, - create_recursive, map_miette, - screen::UICommand, - serial_actor::{ - SerialActor, SerialEvent, SerialMessage, - tasks::{run_file_output, run_stdin_input, run_stdout_output}, - }, -}; -use crossterm::{ - cursor, event, execute, - style::Stylize, - terminal::{self, ClearType}, -}; -use miette::{Context, IntoDiagnostic}; +// use crossterm::event; use serial2_tokio::SerialPort; use std::{ - io::{self, Write}, + // io::{self, Write}, path::PathBuf, }; -use tracing::{Level, trace}; - -/// Spawns all of the tasks responsible for maintaining an interactive terminal session. -pub async fn interactive_session( - connection: SerialPort, - file_path: Option>, - port_name: &str, -) -> miette::Result<()> { - let span = tracing::span!(Level::TRACE, "Interactive Session"); - let _enter = span.enter(); - // Setup terminal - let mut stdout = io::stdout(); - terminal::enable_raw_mode() - .into_diagnostic() - .wrap_err("Failed to enable raw mode.".red())?; - execute!( - stdout, - terminal::EnterAlternateScreen, - terminal::SetTitle(port_name), - terminal::Clear(ClearType::All), - event::EnableBracketedPaste, - event::EnableMouseCapture, - cursor::MoveTo(0, 0) - ) - .into_diagnostic() - .wrap_err("Failed to setup the terminal.".red())?; - let config = get_config().unwrap(); // TODO: HANDLE - trace!("Creating channels"); - // Create channels - let (command_tx, command_rx) = tokio::sync::mpsc::channel::(100); - let (ui_tx, ui_rx) = tokio::sync::mpsc::channel::(100); - let (broadcast_event_tx, _) = tokio::sync::broadcast::channel::(128); - let stdout_rx = broadcast_event_tx.subscribe(); - - // Create tasks - let mut tasks = tokio::task::JoinSet::new(); - - if let Some(maybe_path) = file_path { - let default_out_dir = PathBuf::from(&config.defaults.out_dir); - let file_path = if let Some(path) = maybe_path { - // If given an absolute path - override the `default_out_dir` - if path.is_absolute() { - let parent = path.parent().unwrap_or(&default_out_dir); - create_recursive!(parent); - path - } else { - let joined_path = default_out_dir.join(&path); - let parent_path = joined_path.parent().expect("Does not have root"); - create_recursive!(parent_path); - joined_path - } - } else { - let default_out_dir = PathBuf::from(&config.defaults.out_dir); - compat_port_path!(default_out_dir, port_name) - }; - - let file_rx = broadcast_event_tx.subscribe(); - tasks.spawn(async move { - run_file_output(file_rx, file_path.clone()).await; - run_file_exit_script(file_path); - }); - } - - let actor = SerialActor::new(connection, command_rx, broadcast_event_tx); - tasks.spawn(actor.run()); - - tasks.spawn(run_stdout_output(stdout_rx, ui_rx)); - tasks.spawn(run_stdin_input(command_tx, ui_tx)); - - tasks.join_all().await; - ensure_terminal_cleanup(stdout); - Ok(()) -} +use crate::map_miette; /// Opens a serial `port` for communication with the specified `baud`. /// /// Returns `Ok(SerialPort)` or errors if unable to set the baud rate or open the `port`. -pub fn open_connection(baud: u32, port: &str) -> miette::Result { +pub fn open_connection(baud: u32, port: &PathBuf) -> miette::Result { let settings = |mut s: serial2_tokio::Settings| -> std::io::Result { s.set_raw(); s.set_baud_rate(baud)?; @@ -112,7 +25,7 @@ pub fn open_connection(baud: u32, port: &str) -> miette::Result { }; let con = map_miette!( SerialPort::open(port, settings), - format!("Failed to open port '{}'", port), + format!("Failed to open port '{}'", port.display()), help = "Is the port already open?\nTo see available ports, try `list ports`." )?; Ok(con) @@ -120,49 +33,49 @@ pub fn open_connection(baud: u32, port: &str) -> miette::Result { /// Gets the settings for the `port` with the specified `baud`. #[allow(clippy::many_single_char_names)] -pub fn get_settings(baud: u32, port: &str) -> miette::Result<()> { +pub fn get_settings(baud: u32, port: &PathBuf) -> miette::Result<()> { // https://www.contec.com/support/basic-knowledge/daq-control/serial-communicatin/ let con = open_connection(baud, port)?; let settings = map_miette!( con.get_configuration(), - format!("Failed to get settings for port '{}'", port) + format!("Failed to get settings for port '{}'", port.display()) )?; let b = map_miette!( settings.get_baud_rate(), - format!("Failed to get the baud rate for port '{}'", port) + format!("Failed to get the baud rate for port '{}'", port.display()) )?; let c = map_miette!( settings.get_char_size(), - format!("Failed to get the char size for port '{}'", port) + format!("Failed to get the char size for port '{}'", port.display()) )?; let s = map_miette!( settings.get_stop_bits(), - format!("Failed to get stop bits for port '{}'", port) + format!("Failed to get stop bits for port '{}'", port.display()) )?; let p = map_miette!( settings.get_parity(), - format!("Failed to get parity for port '{}'", port) + format!("Failed to get parity for port '{}'", port.display()) )?; let f = map_miette!( settings.get_flow_control(), - format!("Failed to get flow control for port '{}'", port) + format!("Failed to get flow control for port '{}'", port.display()) )?; let cts = map_miette!( con.read_cts(), - format!("Failed to read CTS for port '{}'", port) + format!("Failed to read CTS for port '{}'", port.display()) )?; let dsr = map_miette!( con.read_dsr(), - format!("Failed to read DSR for port '{}'", port) + format!("Failed to read DSR for port '{}'", port.display()) )?; let ri = map_miette!( con.read_ri(), - format!("Failed to read RI for port '{}'", port) + format!("Failed to read RI for port '{}'", port.display()) )?; let cd = map_miette!( con.read_cd(), - format!("Failed to read CD for port '{}'", port) + format!("Failed to read CD for port '{}'", port.display()) )?; println!("Baud rate: {b}"); @@ -194,8 +107,11 @@ pub fn list_serial_ports() -> miette::Result<()> { Ok(()) } -/// Used as a [`value_parser`](https://docs.rs/clap/latest/clap/struct.Arg.html#method.value_parser) for [`sericom`](https://crates.io/crates/sericom)s [`clap`](https://docs.rs/clap) CLI -/// struct to validate and parse args into a baud rate. +/// Used as a [`value_parser`] for [`sericom`]s [`clap`] CLI +/// +/// [`value_parser`]: https://docs.rs/clap/latest/clap/struct.Arg.html#method.value_parser +/// [`sericom`]: https://crates.io/crates/sericom +/// [`clap`]: https://crates.io/crates/clap pub fn valid_baud_rate(s: &str) -> Result { let baud: u32 = s .parse() @@ -207,8 +123,12 @@ pub fn valid_baud_rate(s: &str) -> Result { } } -/// Used as a [`value_parser`](https://docs.rs/clap/latest/clap/struct.Arg.html#method.value_parser) for [`sericom`](https://crates.io/crates/sericom)s [`clap`](https://docs.rs/clap) CLI -/// struct to validate and parse args into a [`SeriColor`][`crate::configs::SeriColor`]. +/// Used as a [`value_parser`] for [`sericom`]s [`clap`] CLI to parse args into a [`SeriColor`]. +/// +/// [`value_parser`]: https://docs.rs/clap/latest/clap/struct.Arg.html#method.value_parser +/// [`sericom`]: https://crates.io/crates/sericom +/// [`clap`]: https://crates.io/crates/clap +/// [`SeriColor`]: crate::configs::SeriColor pub fn color_parser(input: &str) -> Result { use crate::configs::{NORMALIZER, SeriColor}; match SeriColor::parse_from_str(input, NORMALIZER) { @@ -217,6 +137,7 @@ pub fn color_parser(input: &str) -> Result { } } +/* fn ensure_terminal_cleanup(mut stdout: io::Stdout) { use crossterm::{ cursor::Show, @@ -287,3 +208,4 @@ fn create_platform_cmd( } } } +*/ diff --git a/sericom-core/src/configs/mod.rs b/sericom-core/src/configs/mod.rs index eecf9d2..759d43c 100644 --- a/sericom-core/src/configs/mod.rs +++ b/sericom-core/src/configs/mod.rs @@ -17,7 +17,7 @@ use std::{ io::Read, ops::Range, path::PathBuf, - sync::{LockResult, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard, TryLockResult}, + sync::{LockResult, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard}, }; /// Global value of the user's config. @@ -55,6 +55,18 @@ impl Config { } } +#[cfg(test)] +static INIT: std::sync::Once = std::sync::Once::new(); + +#[cfg(test)] +pub fn init_for_tests() { + INIT.call_once(|| { + CONFIG + .set(RwLock::new(Config::default())) + .expect("init config"); + }); +} + /// This function constructs a global `static CONFIG` for the rest of the program's /// duration to provide a reference to the config for the remainder of the program. /// @@ -85,7 +97,7 @@ pub fn initialize_config(overrides: Option) -> miette::Result<() if let Some(overrides) = overrides { config.apply_overrides(overrides); - }; + } CONFIG .set(RwLock::new(config)) @@ -101,12 +113,16 @@ pub fn initialize_config(overrides: Option) -> miette::Result<() /// /// ## Panics /// Will panic if [`CONFIG`] as not been initialized before calling with [`initialize_config()`]. -pub fn get_config<'a>() -> LockResult> { +pub fn get_config<'a>() -> miette::Result> { // thinking is not try_read because when this method is called, it is // called because the values _are needed_ for initializing other things // so returning an Err from try_read is not helpful - would rather have it // block until it gets a read lock than get an err if it wasn't ready - CONFIG.get().expect("Config not initialized").read() + CONFIG + .get() + .expect("Config not initialized") + .read() + .map_err(|e| miette::miette!("{e}").wrap_err("Failed to read config")) } // TODO: UPDATE DOCS @@ -185,16 +201,16 @@ fn parse_test_config() -> miette::Result<()> { #[test] fn check_conf_dir_is_dir() { let dir = get_conf_dir(); - assert!(std::fs::metadata(dir).unwrap().is_dir()) + assert!(std::fs::metadata(dir).unwrap().is_dir()); } #[test] fn valid_conf_dir() { let dir = get_conf_dir(); if cfg!(target_family = "windows") { - assert_eq!(dir.to_str().unwrap(), "C:\\Users\\Thomas\\.config\\sericom") + assert_eq!(dir.to_str().unwrap(), "C:\\Users\\Thomas\\.config\\sericom"); } else { - assert_eq!(dir.to_str().unwrap(), "/home/thomas/.config/sericom") + assert_eq!(dir.to_str().unwrap(), "/home/thomas/.config/sericom"); } } @@ -219,7 +235,7 @@ fn get_expanded_path() { p2, PathBuf::from("/home/thomas/.config/sericom/config.toml") ); - assert_eq!(p3, PathBuf::from("/home/thomas/.config/some/path")) + assert_eq!(p3, PathBuf::from("/home/thomas/.config/some/path")); } // #[test] diff --git a/sericom-core/src/lib.rs b/sericom-core/src/lib.rs index cc17825..50ddaa8 100644 --- a/sericom-core/src/lib.rs +++ b/sericom-core/src/lib.rs @@ -10,6 +10,7 @@ //! for use among other crates. #![allow(clippy::missing_errors_doc)] #![allow(clippy::missing_panics_doc)] +// #![feature(vec_into_raw_parts)] pub mod cli; pub mod configs; diff --git a/sericom-core/src/path_utils/validators.rs b/sericom-core/src/path_utils/validators.rs index 35336a2..d9c6352 100644 --- a/sericom-core/src/path_utils/validators.rs +++ b/sericom-core/src/path_utils/validators.rs @@ -1,6 +1,7 @@ -use crate::path_utils::ExpandPaths; use std::path::PathBuf; +use crate::path_utils::ExpandPaths; + /// Validates a directory /// /// Expands path and checks if it exists and is a directory. @@ -63,7 +64,8 @@ pub fn is_script(input: &str) -> Result, String> { Ok(Some(p)) } -pub(crate) fn is_executable(path: &std::path::Path) -> bool { +#[must_use] +pub fn is_executable(path: &std::path::Path) -> bool { #[cfg(unix)] { use std::{fs::metadata, os::unix::fs::MetadataExt}; diff --git a/sericom-core/src/screen/buffer.rs b/sericom-core/src/screen/buffer.rs index 52b4de7..73b86f2 100644 --- a/sericom-core/src/screen/buffer.rs +++ b/sericom-core/src/screen/buffer.rs @@ -1,7 +1,8 @@ use crossterm::style::Attributes; use std::collections::VecDeque; +use tracing::trace; -use crate::screen::BuffPos; +use crate::screen::{BuffPos, UICommand}; use super::position::{Position, TermPos, TranslatePos}; use super::{Line, Rect}; @@ -18,8 +19,9 @@ pub struct ScreenBuffer { /// Scrollback buffer (all lines received from the serial connection). /// Limited by [`MAX_SCROLLBACK`]. pub(crate) lines: VecDeque, - /// Current view into the buffer. - /// Denotes which line is at the top of the screen. + /// Denotes which line (as idx in [`Self::lines`]) is at the top of the screen. + /// + /// [`Self::Lines`]: ScreenBuffer::lines pub(crate) view_start: u32, /// The terminal's dimensions pub(crate) rect: Rect, @@ -123,12 +125,37 @@ impl ScreenBuffer { pub(crate) fn buff_rect(&self) -> Rect { Rect::from(( - ( - 0_u16, - u32::try_from(self.view_start).expect("ScreenBuffer is less than usize::MAX"), - ), + (0_u16, self.view_start), self.width(), u32::from(self.height()), )) } + + #[allow(clippy::cast_possible_truncation)] + #[tracing::instrument(skip_all, level = "trace", name = "update_view")] + pub(crate) fn update_view(&mut self, update: Option) { + let buff_rect = self.buff_rect(); + let num_lines = self.lines.len(); + + let Some(cmd) = update else { + if (self.view_start <= num_lines as u32) && ((num_lines as u32) < buff_rect.height) { + trace!(%num_lines, height=%buff_rect.height, view_start=%self.view_start, "lines is within buff_rect"); + return; + } else if num_lines as u32 > buff_rect.height { + // num_lines - 1 because need to be in indexing terms (0 base) + let additional = (num_lines as u32 - 1) - (buff_rect.height + self.view_start); + self.view_start += additional; + trace!( + %num_lines, + height=%buff_rect.height, + view_start=%self.view_start, + %additional, + "lines is greater than buff_rect" + ); + } + return; + }; + + todo!(); + } } diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index bd0a1e2..8bcb3f6 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -112,6 +112,17 @@ impl Line { self.0.push(span); } + pub fn ascii_bytes(&self) -> impl Iterator + '_ { + use std::ops::Deref; + + let end = self.last_filled_idx(); + self.iter() + .flatten() + .take(end + 1) + .map(Deref::deref) + .copied() + } + /// Splits the [`Span`] at `col` and applies `colors` && `attrs` to the new [`Span`]. /// /// If there is only a single [`Span`] in `self` and [`Self::num_filled_cells()`] == 0 diff --git a/sericom-core/src/screen/components/span.rs b/sericom-core/src/screen/components/span.rs index 8ef8713..0eed643 100644 --- a/sericom-core/src/screen/components/span.rs +++ b/sericom-core/src/screen/components/span.rs @@ -43,12 +43,26 @@ impl Span { /// The index of the last [`Cell`] in `self` that is not whitespace. #[must_use] pub fn last_filled_idx(&self) -> usize { - self.len() - - self - .iter() - .rev() - .position(|c| c.character != b' ') - .unwrap_or(0) + if self.all_whitespace() { + return 0; + } + + let len = self.len(); + let p = self + .iter() + .rev() + .position(|c| c.character != b' ') + .unwrap_or(0); + if len - p == len { len - 1 } else { len - p } + } + + fn all_whitespace(&self) -> bool { + for c in &self.cells { + if **c != b' ' { + return false; + } + } + true } fn get_config_colors() -> Colors { @@ -60,12 +74,12 @@ impl Span { } /// Set the [`Attributes`] for `self`. - pub(crate) const fn set_attrs(&mut self, attrs: Attributes) { + pub const fn set_attrs(&mut self, attrs: Attributes) { self.attrs = attrs; } /// Add an [`Attribute`] to [`Self`], if already set this does nothing. - pub(crate) fn add_attr(&mut self, attr: Attribute) { + pub fn add_attr(&mut self, attr: Attribute) { self.attrs.set(attr); } @@ -246,9 +260,6 @@ impl FromIterator for Span { impl std::fmt::Debug for Span { /// Prints "Span[fg: {:?}, bg: {:?}, attrs: {:?}, len: {}, cap: {}] ( {cells} )" fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - use std::fmt::Write; - use std::ops::Deref; - let mut s = format!( "Span[fg: {:?}, bg: {:?}, attrs: {:?}, len: {}, cap: {}] ( ", self.colors.foreground.unwrap(), diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/driver.rs index 52e7af0..f749c2b 100644 --- a/sericom-core/src/screen/driver.rs +++ b/sericom-core/src/screen/driver.rs @@ -1,6 +1,7 @@ use std::fmt::Write; use crossterm::style::Attributes; +use tracing::trace; use crate::screen::process::SEP; @@ -26,15 +27,26 @@ impl<'a> ScreenDriver<'a> { } } - pub fn process_events(&mut self, events: Vec) { + /// Returns the number of newlines written + pub fn process_events(&mut self, events: Vec) -> u32 { + let span = tracing::trace_span!("process"); + let _enter = span.enter(); + + let mut newlines = 0; for event in events { - tracing::trace!(target: "parser::events", %event); + trace!(%event); match event { ParserEvent::Text(bytes) => self.write_text(&bytes), - ParserEvent::Control(ctrl) => self.handle_control(ctrl), + ParserEvent::Control(ctrl) => { + if ctrl == b'\n' { + newlines += 1; + } + self.handle_control(ctrl); + } ParserEvent::EscapeSequence(seq) => self.handle_escape(&seq), } } + newlines } fn write_text(&mut self, bytes: &[u8]) { @@ -65,12 +77,12 @@ impl<'a> ScreenDriver<'a> { .expect("span len is greater than last filled cell") .character = b'\n'; } - tracing::trace!(target: "parser::newline", ?line); }); self.buffer.set_cursor_col(0); self.buffer.move_cursor_down(1); self.buffer .push_line(Line::new_empty(self.buffer.width() as usize)); + self.buffer.update_view(None); } TAB => { self.buffer.with_current_span(|span, _| { diff --git a/sericom-core/src/screen/mod.rs b/sericom-core/src/screen/mod.rs index 22c8c24..fa16427 100644 --- a/sericom-core/src/screen/mod.rs +++ b/sericom-core/src/screen/mod.rs @@ -16,8 +16,8 @@ //! connection in a [`VecDeque`]. It is important to note that //! currently, the **capacity of the [`VecDeque`] is hardcoded with a value of 10,000 //! lines with [`MAX_SCROLLBACK`]**. -#![allow(dead_code)] -#![allow(unused)] +// #![allow(dead_code)] +// #![allow(unused)] mod buffer; mod components; diff --git a/sericom-core/src/screen/process/colors.rs b/sericom-core/src/screen/process/colors.rs index 8507ee0..d74ea08 100644 --- a/sericom-core/src/screen/process/colors.rs +++ b/sericom-core/src/screen/process/colors.rs @@ -18,6 +18,7 @@ impl Default for ColorState { let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); Self { colors: Colors::new(fg, bg), } diff --git a/sericom-core/src/screen/render.rs b/sericom-core/src/screen/render.rs index 61f92e0..3e27193 100644 --- a/sericom-core/src/screen/render.rs +++ b/sericom-core/src/screen/render.rs @@ -19,40 +19,6 @@ impl ScreenBuffer { // self.process_events(writer, event); } - // fn add_char_batch(&mut self, chars: &[char]) { - // tracing::debug!("CharBatch: '{:?}'", chars); - // while self.cursor.y >= self.lines.len() { - // self.lines.push_back(Line::new_default(self.width.into())); - // } - // - // if let Some(line) = self.lines.get_mut(self.cursor.y) { - // for &ch in chars { - // line.set_char(self.cursor.x as usize, ch); - // self.cursor.x += 1; - // if self.cursor.x >= self.width { - // self.new_line(); - // break; - // } - // } - // } - // } - - /// A helper function to check whether the terminal's screen should be rendered. - pub fn should_render_now(&self) -> bool { - // use tokio::time::Instant; - // - // if !self.needs_render { - // return false; - // } - // - // let now = Instant::now(); - // match self.last_render { - // Some(last) => now.duration_since(last) >= MIN_RENDER_INTERVAL, - // None => true, - // } - true - } - /// Writes the lines/characters received from `add_data` to the terminal's screen. /// /// As of now, `render` does not involve any diff-ing of previous renders. diff --git a/sericom-core/src/screen/tests/colors.rs b/sericom-core/src/screen/tests/colors.rs index d0d9d90..42f4767 100644 --- a/sericom-core/src/screen/tests/colors.rs +++ b/sericom-core/src/screen/tests/colors.rs @@ -1,13 +1,7 @@ -use crate::configs::{ConfigOverride, get_config, initialize_config}; +use crate::configs::get_config; use crate::screen::process::*; use crossterm::style::{Attribute, Attributes, Color}; -const CONF_OR: ConfigOverride = ConfigOverride { - color: None, - out_dir: None, - script: None, -}; - struct Case<'a> { seq: &'a [u8], expected_fg: Option, @@ -55,9 +49,10 @@ fn test_cases(cases: &Vec) { #[test] fn test_basic_fg() { - initialize_config(Some(CONF_OR)).ok(); + crate::configs::init_for_tests(); let config = get_config().unwrap(); let bg = Color::from(&config.appearance.bg); + drop(config); let cases = vec![ Case { @@ -80,9 +75,10 @@ fn test_basic_fg() { #[test] fn test_basic_bg() { - initialize_config(Some(CONF_OR)).ok(); + crate::configs::init_for_tests(); let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); + drop(config); let cases = vec![Case { seq: b"\x1b[44m", @@ -96,10 +92,11 @@ fn test_basic_bg() { #[test] fn test_bright_colors() { - initialize_config(Some(CONF_OR)).ok(); + crate::configs::init_for_tests(); let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); let cases = vec![ Case { @@ -122,10 +119,11 @@ fn test_bright_colors() { #[test] fn test_resets_and_defaults() { - initialize_config(Some(CONF_OR)).ok(); + crate::configs::init_for_tests(); let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); let cases = vec![ Case { @@ -155,10 +153,11 @@ fn test_resets_and_defaults() { #[test] fn test_attributes() { - initialize_config(Some(CONF_OR)).ok(); + crate::configs::init_for_tests(); let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); let cases = vec![Case { seq: b"\x1b[1;3;4m", @@ -172,10 +171,11 @@ fn test_attributes() { #[test] fn test_256_color_palette() { - initialize_config(Some(CONF_OR)).ok(); + crate::configs::init_for_tests(); let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); let cases = vec![ Case { @@ -198,10 +198,11 @@ fn test_256_color_palette() { #[test] fn test_truecolor_palette() { - initialize_config(Some(CONF_OR)).ok(); + crate::configs::init_for_tests(); let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); let cases = vec![ Case { @@ -232,10 +233,11 @@ fn test_truecolor_palette() { #[test] fn test_mix_attr_colors() { - initialize_config(Some(CONF_OR)).ok(); + crate::configs::init_for_tests(); let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); let cases = vec![ Case { @@ -262,10 +264,11 @@ fn test_mix_attr_colors() { #[test] fn test_kitchen_sink() { - initialize_config(Some(CONF_OR)).ok(); + crate::configs::init_for_tests(); let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); let cases = vec![ Case { @@ -292,10 +295,11 @@ fn test_kitchen_sink() { #[test] fn test_invalid() { - initialize_config(Some(CONF_OR)).ok(); + crate::configs::init_for_tests(); let config = get_config().unwrap(); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); let cases = vec![ Case { diff --git a/sericom-core/src/screen/tests/components.rs b/sericom-core/src/screen/tests/components.rs index 9b3a341..0ab0c5a 100644 --- a/sericom-core/src/screen/tests/components.rs +++ b/sericom-core/src/screen/tests/components.rs @@ -1,4 +1,3 @@ -use crate::setup; use std::fmt::Write; use std::io; @@ -7,7 +6,6 @@ use crossterm::execute; use crossterm::style::Colors; use crossterm::style::SetAttribute; use crossterm::style::SetColors; -use crossterm::style::SetForegroundColor; // Stole this from crossterm's test - thanks! #[derive(Default, Debug, Clone)] @@ -32,8 +30,9 @@ impl io::Write for FakeWrite { } #[test] +#[allow(unused)] fn line_as_command() { - initialize_config(Some(CONFIG_OVERRIDE)); + crate::configs::init_for_tests(); let mut writer = FakeWrite { buffer: String::new(), flushed: false, @@ -47,7 +46,7 @@ fn line_as_command() { let mut span2: Span = "second span".chars().collect(); span2.set_colors(&colors); span2.set_attrs(attrs); - let mut span3: Span = "third span\n".chars().collect(); + let span3: Span = "third span\n".chars().collect(); let line = Line(vec![span1, span2, span3]); execute!(writer, line); @@ -86,7 +85,7 @@ fn overwriting_span() { #[test] fn span_newline() { - initialize_config(Some(CONFIG_OVERRIDE)); + crate::configs::init_for_tests(); let mut span: Span = "my test span ".chars().collect(); let res = span.last_filled_idx(); @@ -110,10 +109,10 @@ fn span_newline() { #[test] fn line_last_filled() { - initialize_config(Some(CONFIG_OVERRIDE)); + crate::configs::init_for_tests(); - let mut span1: Span = "first span".chars().collect(); - let mut span2: Span = "second span ".chars().collect(); + let span1: Span = "first span".chars().collect(); + let span2: Span = "second span ".chars().collect(); let line = Line(vec![span1, span2]); assert_eq!(line.last_filled_idx(), 20); @@ -122,15 +121,15 @@ fn line_last_filled() { #[test] fn line_num_filled() { - initialize_config(Some(CONFIG_OVERRIDE)); + crate::configs::init_for_tests(); - let mut span1: Span = "first span".chars().collect(); - let mut span2: Span = "second span ".chars().collect(); + let span1: Span = "first span".chars().collect(); + let span2: Span = "second span ".chars().collect(); let line = Line(vec![span1, span2]); assert_eq!(line.filled_cells(), 21); - let mut span: Span = " ".chars().collect(); + let span: Span = " ".chars().collect(); let line = Line(vec![span]); assert_eq!(line.filled_cells(), 0); diff --git a/sericom-core/src/screen/tests/cursor.rs b/sericom-core/src/screen/tests/cursor.rs index 9f6f597..d405e54 100644 --- a/sericom-core/src/screen/tests/cursor.rs +++ b/sericom-core/src/screen/tests/cursor.rs @@ -22,7 +22,7 @@ const RESET: &[u8] = &[ESC, BK, b'0', b'm']; #[test] fn clear_line_from_cursor() { - setup!(sb, parser, _config, stdout); + setup!(sb, parser); let s = "This is the first line\nThis is the second line\nThis is the third line\n"; let s = [ s.as_bytes(), @@ -42,7 +42,7 @@ fn clear_line_from_cursor() { #[test] fn clear_from_cursor_to_top() { - setup!(sb, parser, _config, stdout); + setup!(sb, parser); let s = "This is the first line\n".to_owned() + "This is the second line\n" + "This is the third line\n" @@ -70,7 +70,7 @@ fn clear_from_cursor_to_top() { #[test] fn clear_and_overwrite() { - setup!(sb, parser, _config, stdout); + setup!(sb, parser); let s = "This is the first line\n".to_owned() + "This is the second line\n" + "This is the third line\n" diff --git a/sericom-core/src/screen/tests/escape.rs b/sericom-core/src/screen/tests/escape.rs index d6f8e0d..df7d953 100644 --- a/sericom-core/src/screen/tests/escape.rs +++ b/sericom-core/src/screen/tests/escape.rs @@ -3,12 +3,13 @@ use crate::{assert_line_eq, assert_span_eq, setup}; #[test] fn single_plain_line() { - setup!(sb, parser, config, stdout); + setup!(sb, parser, config); let parsed = parser.feed(b"Hello, world!\n"); let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); // Changed pos.x == 0 because handling \n like \r\n for now // assert_eq!(sb.cursor, Position::::from((13_u16, 1_u16))); @@ -20,12 +21,13 @@ fn single_plain_line() { #[test] fn two_lines_plain_text() { - setup!(sb, parser, config, stdout); + setup!(sb, parser, config); let parsed = parser.feed(b"Hello\r\nWorld\r\n"); let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); // Expected: two lines, one with "Hello" padded, one with "World" padded assert_eq!(sb.lines.len(), 3); @@ -38,11 +40,12 @@ fn two_lines_plain_text() { #[test] fn three_color_spans() { - setup!(sb, parser, config, stdout); + setup!(sb, parser, config); let parsed = parser.feed(b"\x1b[31mRed\x1b[32mGreen\x1b[34mBlue\n"); let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let bg = Color::from(&config.appearance.bg); + drop(config); let line = sb.lines.front().unwrap(); eprintln!( @@ -62,12 +65,13 @@ fn three_color_spans() { #[test] fn no_newline_incomplete_line() { - setup!(sb, parser, config, stdout); + setup!(sb, parser, config); let parsed = parser.feed(b"Hello"); let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); // Should still only contain the initial empty line assert_eq!(sb.lines.len(), 1); @@ -77,12 +81,13 @@ fn no_newline_incomplete_line() { #[test] fn mixed_plain_and_color() { - setup!(sb, parser, config, stdout); + setup!(sb, parser, config); let parsed = parser.feed(b"Normal \x1b[31mRed\n"); let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); // Expect two spans: "Normal " default, "Red" DarkRed let line = sb.lines.front().unwrap(); @@ -91,7 +96,6 @@ fn mixed_plain_and_color() { // 2 spans assert_eq!(line.len(), 2); // Changed pos.x == 0 because handling \n like \r\n for now - // assert_eq!(sb.cursor, Position::::from((10_u16, 1_u16))); assert_eq!(sb.cursor, Position::::from((0_u16, 1_u16))); assert_span_eq!(sb, 0, 0, expected => "Normal ", fg => fg, bg => bg); assert_span_eq!(sb, 0, 1, expected => "Red", fg => Color::DarkRed, bg => bg); @@ -99,7 +103,7 @@ fn mixed_plain_and_color() { #[test] fn bold_italic_span() { - setup!(sb, parser, stdout); + setup!(sb, parser); let parsed = parser.feed(b"\x1b[1;3mHello\n"); // bold + italic let mut driver = ScreenDriver::new(&mut sb); @@ -112,9 +116,10 @@ fn bold_italic_span() { #[test_log::test] #[allow(clippy::cognitive_complexity)] fn multiline_multicolor() { - setup!(sb, parser, config, stdout); + setup!(sb, parser, config); let fg = Color::from(&config.appearance.fg); let bg = Color::from(&config.appearance.bg); + drop(config); let input = concat!( // Line 1: basic color DarkRed -> text diff --git a/sericom-core/src/screen/tests/mod.rs b/sericom-core/src/screen/tests/mod.rs index 1bb9e02..8035b04 100644 --- a/sericom-core/src/screen/tests/mod.rs +++ b/sericom-core/src/screen/tests/mod.rs @@ -3,36 +3,26 @@ mod components; mod cursor; mod escape; -pub use crate::{ - configs::{ConfigOverride, initialize_config}, - screen::{driver::ScreenDriver, *}, -}; +pub use crate::screen::{driver::ScreenDriver, *}; pub use crossterm::style::{Attribute, Attributes, Color}; pub use std::collections::VecDeque; -pub const CONFIG_OVERRIDE: ConfigOverride = ConfigOverride { - color: None, - out_dir: None, - script: None, -}; pub const TERMINAL_SIZE: (u16, u16) = (80, 24); #[macro_export] macro_rules! setup { - ($sb:ident, $parser:ident, $stdout:ident) => { - initialize_config(Some(CONFIG_OVERRIDE)).ok(); + ($sb:ident, $parser:ident) => { + $crate::configs::init_for_tests(); let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); let mut $sb = ScreenBuffer::new(rect); let mut $parser = ByteParser::new(); - let mut $stdout = std::io::stdout(); }; - ($sb:ident, $parser:ident, $config:ident, $stdout:ident) => { - initialize_config(Some(CONFIG_OVERRIDE)).ok(); + ($sb:ident, $parser:ident, $config:ident) => { + $crate::configs::init_for_tests(); let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); let mut $sb = ScreenBuffer::new(rect); let mut $parser = ByteParser::new(); let $config = $crate::configs::get_config().unwrap(); - let mut $stdout = std::io::stdout(); }; } diff --git a/sericom-core/src/serial_actor/mod.rs b/sericom-core/src/serial_actor/mod.rs index 5d70cbb..7c997f6 100644 --- a/sericom-core/src/serial_actor/mod.rs +++ b/sericom-core/src/serial_actor/mod.rs @@ -31,10 +31,12 @@ pub enum SerialEvent { /// shutdown the connection. `ConnectionClosed` is used for the [`SerialActor`] /// to broadcast to listeners that the connection has been shutdown by the device. ConnectionClosed, + LinesWritten(u32), } /// Responsible for passing data and messages between the serial connection and tasks. -/// It uses the Actor model to maintain a single source for communicating between the +/// +/// Uses the Actor model to maintain a single source for communicating between the /// serial connection and tasks within the program. /// /// It broadcasts [`SerialEvent`]s to worker tasks via a [`tokio::sync::broadcast`] @@ -71,45 +73,54 @@ impl SerialActor { /// /// Since data is sent byte-by-byte over a serial connection, `run` will /// batch the data before sending it to other tasks to reduce the number of syscalls. - pub async fn run(mut self) { - tracing::trace!(target: "session::actor", "running SerialActor"); - let mut buffer = vec![0u8; 4096]; + #[tracing::instrument(skip_all, level = "debug")] + pub async fn run(mut self) -> miette::Result<()> { + use tracing::{debug, error, trace}; + + trace!("running SerialActor"); + let mut buffer = vec![0u8; 2048]; loop { tokio::select! { // Handle commands/input from tasks cmd = self.command_rx.recv() => { + trace!(?cmd, "command_rx.recv()"); match cmd { Some(SerialMessage::Write(data)) => { if let Err(e) = self.connection.write_all(&data).await { + error!("error writing to connection: {e}"); self.broadcast_channel.send(SerialEvent::Error(e.to_string())).ok(); } } Some(SerialMessage::Shutdown) => { - tracing::debug!(target: "session::actor", "recieved shutdown command"); + debug!("recieved shutdown command"); self.broadcast_channel.send(SerialEvent::ConnectionClosed).ok(); break; } Some(SerialMessage::SendBreak) => { - tracing::debug!(target: "session::actor", "sending break signal"); + debug!("sending break signal"); self.send_break().await; } - None => break, + None => { + debug!("command_rx.recv() was None"); + break; + } } } // Handle reading data from serial connection read_result = self.connection.read(&mut buffer) => { match read_result { Ok(0) => { - tracing::debug!(target: "session::actor", "connection closed: no bytes read"); + debug!("closing connection - no bytes read"); self.broadcast_channel.send(SerialEvent::ConnectionClosed).ok(); break; } Ok(n) => { let data: std::sync::Arc<[u8]> = buffer[..n].into(); + debug!("recieved {} bytes", data.len()); self.broadcast_channel.send(SerialEvent::Data(data)).ok(); } Err(e) => { - tracing::warn!(target: "session::actor", "error reading from connection."); + error!("error reading from connection: {e}"); self.broadcast_channel.send(SerialEvent::Error(e.to_string())).ok(); break; } @@ -117,6 +128,8 @@ impl SerialActor { } } } + + Ok(()) } async fn send_break(&self) { diff --git a/sericom-core/src/serial_actor/tasks.rs b/sericom-core/src/serial_actor/tasks.rs index 51de76c..0ca89e6 100644 --- a/sericom-core/src/serial_actor/tasks.rs +++ b/sericom-core/src/serial_actor/tasks.rs @@ -1,18 +1,7 @@ -use crate::{ - screen::{ByteParser, Position, Rect, ScreenBuffer, UIAction, UICommand}, - serial_actor::{SerialEvent, SerialMessage}, - ui::Terminal, -}; -use crossterm::{ - event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}, - terminal, -}; -use std::{ - fs::File, - io::{BufWriter, Write}, - path::PathBuf, -}; -use tracing::{error, info, instrument}; +use crate::{screen::UICommand, serial_actor::SerialMessage}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; + +use tracing::instrument; const UTF_TAB: &str = "\u{0009}"; const UTF_BKSP: &str = "\u{0008}"; @@ -24,105 +13,6 @@ const UTF_DOWN_KEY: &str = "\u{001B}\u{005B}\u{0042}"; const UTF_LEFT_KEY: &str = "\u{001B}\u{005B}\u{0044}"; const UTF_RIGHT_KEY: &str = "\u{001B}\u{005B}\u{0043}"; -/// Responsible for receiving incoming data from the [`SerialActor`] and -/// rendering terminal output via the [`ScreenBuffer`]. -#[instrument(skip_all, name = "Stdout")] -pub async fn run_stdout_output( - mut con_rx: tokio::sync::broadcast::Receiver, - mut ui_rx: tokio::sync::mpsc::Receiver, -) { - let (width, height) = terminal::size().unwrap_or((80, 24)); - let mut screen_buffer = ScreenBuffer::new(Rect { - width, - height, - origin: Position::ORIGIN, - }); - let mut data_buffer = Vec::with_capacity(2048); - let mut render_timer: Option = None; - - loop { - tokio::select! { - serial_event = con_rx.recv() => { - match serial_event { - Ok(SerialEvent::Data(data)) => { - data_buffer.extend_from_slice(&data); - - if data_buffer.len() > 1024 || data.contains(&b'\n') { - // screen_buffer.add_data(&data_buffer); - data_buffer.clear(); - if screen_buffer.should_render_now() { - screen_buffer.render().ok(); - render_timer = None; - } else if render_timer.is_none() { - render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); - } - } else if screen_buffer.should_render_now() { - // screen_buffer.add_data(&data_buffer); - data_buffer.clear(); - - screen_buffer.render().ok(); - } else if render_timer.is_none() { - render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); - } - } - Ok(SerialEvent::Error(e)) => { - let error_msg = format!("[ERROR] {e}\r\n"); - error!("Added data: {:?}", data_buffer); - // screen_buffer.add_data(error_msg.as_bytes()); - screen_buffer.render().ok(); - render_timer = None; - } - Ok(SerialEvent::ConnectionClosed) | Err(_) => break, - } - } - ui_command = ui_rx.recv() => { - // debug!("Sending UICommand: {:?}", ui_command); - match ui_command { - Some(UICommand::ScrollUp(lines)) => { - screen_buffer.scroll_up(lines); - } - Some(UICommand::ScrollDown(lines)) => { - screen_buffer.scroll_down(lines); - } - Some(UICommand::ScrollTop) => { - screen_buffer.scroll_to_top(); - } - Some(UICommand::ScrollBottom) => { - screen_buffer.scroll_to_bottom(); - } - Some(UICommand::StartSelection(pos)) => { - screen_buffer.start_selection(pos); - } - Some(UICommand::UpdateSelection(pos)) => { - screen_buffer.update_selection(pos); - } - Some(UICommand::CopySelection) => { - screen_buffer.copy_to_clipboard().ok(); - } - Some(UICommand::ClearBuffer) => { - screen_buffer.clear_buffer(); - } - None => break, - } - screen_buffer.render().ok(); - render_timer = None; - } - () = async { - if let Some(ref mut timer) = render_timer { - timer.tick().await; - } else { - std::future::pending::<()>().await; - } - } => { - if screen_buffer.should_render_now() { - screen_buffer.render().ok(); - render_timer = None; - } - } - } - } -} - /// Responsible for spawning a blocking task with [`tokio::task::spawn_blocking()`] /// and processing user input from stdin. /// @@ -177,7 +67,7 @@ fn stdin_input_loop( let _ = ui_tx.blocking_send(UICommand::ScrollBottom); } _ => {} - }; + } } // Match Alt + Code Event::Key(KeyEvent { @@ -189,9 +79,9 @@ fn stdin_input_loop( if kind != crossterm::event::KeyEventKind::Press { continue; } - if let KeyCode::Char('b') = code { + if KeyCode::Char('b') == code { let _ = command_tx.blocking_send(SerialMessage::SendBreak); - }; + } } // Match Control + Code Event::Key(KeyEvent { @@ -215,7 +105,7 @@ fn stdin_input_loop( break; } _ => {} - }; + } } // Match every other key Event::Key(KeyEvent { @@ -269,200 +159,3 @@ fn stdin_input_loop( } } } - -/// Responsible for spawning a blocking task with [`tokio::task::spawn_blocking()`] -/// and forwarding the incoming data received from the [`SerialActor`] to the blocking -/// task to write to a file. -#[instrument(name = "File output", skip(file_rx))] -pub async fn run_file_output( - mut file_rx: tokio::sync::broadcast::Receiver, - file_path: PathBuf, -) { - let (write_tx, write_rx) = std::sync::mpsc::channel::>(); - info!("Creating file: '{}'", file_path.display()); - let write_handle = tokio::task::spawn_blocking(move || { - let file = match File::create(&file_path) { - Ok(f) => f, - Err(e) => { - eprintln!("Failed to create file '{}': {e}", file_path.display()); - return; - } - }; - let mut writer = BufWriter::with_capacity(8 * 1024, file); - let mut last_flush = std::time::Instant::now(); - - writeln!(writer, "Session started at: {}", chrono::Utc::now()).ok(); - while let Ok(data) = write_rx.recv() { - writer.write_all(&data).ok(); - let now = std::time::Instant::now(); - if now.duration_since(last_flush) > std::time::Duration::from_millis(200) - || writer.buffer().len() > 4 * 1024 - { - let _ = writer.flush(); - last_flush = now; - } - } - let _ = writer.flush(); - }); - - let data_streamer = tokio::spawn(async move { - let mut write_buf = Vec::with_capacity(4096); - let mut batch_timer = tokio::time::interval(tokio::time::Duration::from_millis(200)); - - loop { - tokio::select! { - event = file_rx.recv() => { - match event { - Ok(SerialEvent::Data(data)) => { - write_buf.extend_from_slice(&data); - if write_buf.len() >= 4096 && write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - } - Ok(SerialEvent::Error(e)) => { - if !write_buf.is_empty() { - if write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - write_buf.clear(); - } - let error_msg = format!("\r\n[ERROR {}] {e}\r\n", chrono::Utc::now()); - let _ = write_tx.send(error_msg.into_bytes()); - } - Ok(SerialEvent::ConnectionClosed) => { - if !write_buf.is_empty() { - if write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - write_buf.clear(); - } - let close_msg = format!("\r\n[CLOSED {}] Connection closed.\r\n", chrono::Utc::now()); - let _ = write_tx.send(close_msg.into_bytes()); - break; - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - eprintln!("File writer lagged, skipped {skipped} messages"); - } - _ => break, - } - } - _ = batch_timer.tick() => { - if !write_buf.is_empty() && write_tx.send(std::mem::take(&mut write_buf)).is_err() { - break; - } - } - } - } - if !write_buf.is_empty() { - let _ = write_tx.send(std::mem::take(&mut write_buf)); - } - drop(write_tx); - }); - - let _ = data_streamer.await; - let _ = write_handle.await; -} - -pub async fn _run_stdout_output( - mut con_rx: tokio::sync::broadcast::Receiver, - mut ui_rx: tokio::sync::mpsc::Receiver, -) { - let mut terminal = Terminal::default(); - let screen_buffer = ScreenBuffer::new(terminal.area()); - let mut data_buffer: Vec = Vec::with_capacity(2048); - let mut render_timer: Option = None; - - ///////// - let mut parser = ByteParser::new(); - - terminal.draw(|frame| { - frame.render_widget(screen_buffer, frame.area()); - }); - - loop { - tokio::select! { - serial_event = con_rx.recv() => { - match serial_event { - Ok(SerialEvent::Data(data)) => { - data_buffer.extend_from_slice(&data); - // screen_buffer.add_data(&data_buffer); - // data_buffer.clear(); - if data_buffer.len() > 1024 || data.contains(&b'\n') { - let parsed = parser.feed(&data_buffer); - tracing::debug!("{:?}", parsed); - data_buffer.clear(); - - // if screen_buffer.should_render_now() { - // screen_buffer.render().ok(); - // render_timer = None; - // } else if render_timer.is_none() { - // render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); - // } - } - // } else if &screen_buffer.should_render_now() { - // let parsed = parser.feed(&data_buffer); - // tracing::debug!("{:?}", parsed); - // data_buffer.clear(); - // - // screen_buffer.render().ok(); - // } else if render_timer.is_none() { - // render_timer = Some(tokio::time::interval(tokio::time::Duration::from_millis(16))); - // } - } - Ok(SerialEvent::Error(_)) => { - // let error_msg = format!("[ERROR] {e}\r\n"); - // error!("Added data: {:?}", data_buffer); - // screen_buffer.add_data(error_msg.as_bytes()); - // screen_buffer.render().ok(); - // render_timer = None; - } - Ok(SerialEvent::ConnectionClosed) | Err(_) => break, - } - } - ui_command = ui_rx.recv() => { - // debug!("Sending UICommand: {:?}", ui_command); - // match ui_command { - // Some(UICommand::ScrollUp(lines)) => { - // screen_buffer.scroll_up(lines); - // } - // Some(UICommand::ScrollDown(lines)) => { - // screen_buffer.scroll_down(lines); - // } - // Some(UICommand::ScrollTop) => { - // screen_buffer.scroll_to_top(); - // } - // Some(UICommand::ScrollBottom) => { - // screen_buffer.scroll_to_bottom(); - // } - // Some(UICommand::StartSelection(pos)) => { - // screen_buffer.start_selection(pos); - // } - // Some(UICommand::UpdateSelection(pos)) => { - // screen_buffer.update_selection(pos); - // } - // Some(UICommand::CopySelection) => { - // screen_buffer.copy_to_clipboard().ok(); - // } - // Some(UICommand::ClearBuffer) => { - // screen_buffer.clear_buffer(); - // } - // None => break, - // } - // screen_buffer.render().ok(); - // render_timer = None; - } - () = async { - if let Some(ref mut timer) = render_timer { - timer.tick().await; - } else { - std::future::pending::<()>().await; - } - } => { - // if screen_buffer.should_render_now() { - // screen_buffer.render().ok(); - // render_timer = None; - // } - } - } - } -} diff --git a/sericom-core/src/session/handle.rs b/sericom-core/src/session/handle.rs index 24a9ece..2904a17 100644 --- a/sericom-core/src/session/handle.rs +++ b/sericom-core/src/session/handle.rs @@ -1,9 +1,13 @@ -use std::sync::Arc; -use tokio::task::JoinSet; -use tracing::Instrument; +use miette::{Context, IntoDiagnostic}; +use std::{path::PathBuf, sync::Arc}; +use tokio::{io::AsyncWriteExt, task::JoinSet}; +use tracing::{Instrument, debug, error, info, trace}; use crate::{ cli::open_connection, + compat_port_path, + configs::get_config, + create_recursive, screen::{Rect, ScreenBuffer}, serial_actor::{SerialActor, SerialEvent, SerialMessage}, }; @@ -30,82 +34,321 @@ pub struct SessionHandle { /// /// [`Receiver`]: tokio::sync::broadcast::Receiver /// [`SessionManager`]: super::SessionManager - pub(crate) events: tokio::sync::broadcast::Receiver, + pub(crate) events: tokio::sync::broadcast::Sender, /// A sessions async tasks. /// /// Currently a session only has two tasks, the [`SerialActor::run`] and /// the task responsible for parsing the session's incoming data/byte stream. /// The [`JoinSet`] is used so that when a session is terminated, all tasks /// associated with a session can be gracefully killed together. - pub(crate) tasks: JoinSet<()>, + pub(crate) tasks: JoinSet>, } impl SessionHandle { - /// Spawn a new session for `port` with `baud`. - #[allow(clippy::result_unit_err)] - pub fn spawn(meta: &super::SessionMeta) -> miette::Result { + /// Spawn a new session and optionally stream to a `file` or run in headless mode. + pub fn spawn( + meta: &super::SessionMeta, + with_file: Option>, + headless: bool, + ) -> miette::Result { let (tx, rx) = tokio::sync::mpsc::channel::(100); let (events_tx, _) = tokio::sync::broadcast::channel::(128); - let session_events = events_tx.subscribe(); - let parser_events = events_tx.subscribe(); + let connection = open_connection(meta.baud, &meta.port)?; + let (term_w, term_h) = if headless { + #[cfg(not(test))] + { + (80, 24) + } - let connection = open_connection(meta.baud, &meta.port)?; // TODO: HANDLE ERROR - let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 20)); + #[cfg(test)] + { + (120, 10) + } + } else { + crossterm::terminal::size().unwrap_or((80, 24)) + }; let sb = ScreenBuffer::new(Rect::new((0u16, 0u16).into(), term_w, term_h)); let buffer = Arc::new(tokio::sync::RwLock::new(sb)); - let actor = SerialActor::new(connection, rx, events_tx); + let actor = SerialActor::new(connection, rx, events_tx.clone()); - let span = tracing::info_span!("session", port = %meta.port, baud = %meta.baud); - let _enter = span.enter(); - tracing::info!(target: "session::handle::spawn", "Opened connection"); + let span = { + let info = tracing::info_span!("session"); + let dbg = tracing::debug_span!("session", port = %meta.port.display()); + if dbg.is_disabled() { info } else { dbg } + }; - let mut tasks = JoinSet::new(); - tasks.spawn(actor.run().instrument(span.clone())); - tasks.spawn(parse_task(Arc::clone(&buffer), parser_events).instrument(span.clone())); + { + let _enter = span.enter(); + debug!(port=%meta.port.display(), baud=%meta.baud, "opened connection"); + } - Ok(Self { + let mut handle = Self { buffer, tx, - events: session_events, - tasks, - }) + events: events_tx, + tasks: JoinSet::new(), + }; + handle.tasks.spawn(actor.run().instrument(span.clone())); + span.in_scope(|| handle.spawn_parse_task()); + + if with_file.is_some() { + let config = get_config()?; + let default_out_dir = PathBuf::from(&config.defaults.out_dir); + let file_path = if let Some(Some(path)) = with_file { + // If given an absolute path - override the `default_out_dir` + if path.is_absolute() { + let parent = path.parent().unwrap_or(&default_out_dir); + create_recursive!(parent); + path + } else { + let joined_path = default_out_dir.join(&path); + let parent_path = joined_path.parent().expect("Does not have root"); + create_recursive!(parent_path); + joined_path + } + } else { + let default_out_dir = PathBuf::from(&config.defaults.out_dir); + drop(config); + compat_port_path!(default_out_dir, &meta.port) + }; + span.in_scope(|| handle.spawn_file_task(&file_path))?; + } + + Ok(handle) } + /// Shutdown this session. + /// + /// Signals associated tasks to shutdown and waits for them to complete. pub async fn shutdown(self) { if let Err(e) = self.tx.send(SerialMessage::Shutdown).await { - tracing::debug!(target: "session::handle::shutdown", %e, "error sending shutdown to actor"); + debug!("error sending shutdown to actor: {e}"); let mut tasks = self.tasks; tasks.abort_all(); return; } self.tasks.join_all().await; - tracing::trace!(target: "session::handle::shutdown", "all tasks finished, shutting down"); + trace!("all tasks finished, shutting down"); } -} -async fn parse_task( - buffer: Arc>, - mut events: tokio::sync::broadcast::Receiver, -) { - use crate::screen::{ByteParser, ScreenDriver}; + /// Spawns a task that writes the session's output to a file. + #[tracing::instrument(skip_all, name = "file_task")] + pub fn spawn_file_task(&mut self, f_path: &PathBuf) -> miette::Result<()> { + use std::fs::File; + use tokio::fs; + + // Creating the file outside of the task to propogate errors + // since SessionHandle doesn't have a good way to listen for task + // errors and act accordingly - TODO + let file = File::create(f_path) + .into_diagnostic() + .inspect_err(|_| { + error!("failed to create file: {}", f_path.display()); + }) + .wrap_err(format!("Failed to create file: {}", f_path.display()))?; + + info!(file=%f_path.display(), "created file"); - let mut parser = ByteParser::new(); + let mut events_rx = self.events.subscribe(); + let buffer_arc = Arc::clone(&self.buffer); - while let Ok(event) = events.recv().await { - match event { - SerialEvent::Data(bytes) => { - let parsed = parser.feed(&bytes); + let span = tracing::Span::current(); + self.tasks.spawn( + async move { + let mut file = fs::File::from_std(file); + let mut last_idx = buffer_arc.read().await.view_start; - let mut buf = buffer.write().await; - ScreenDriver::new(&mut buf).process_events(parsed); + while let Ok(event) = events_rx.recv().await { + match event { + SerialEvent::LinesWritten(0) => {} + SerialEvent::LinesWritten(1) => { + trace!("LinesWritten=1"); + let sb = buffer_arc.read().await; + if sb.view_start.saturating_sub(1) == last_idx { + continue; + } + last_idx += 1; + + trace!(%last_idx, view_start=%sb.view_start); + + if let Some(line) = sb.lines.get(last_idx as usize) { + file.write_all(&line.ascii_bytes().collect::>()) + .await + .into_diagnostic() + .wrap_err("Failed to write to file")?; + } + drop(sb); + } + SerialEvent::LinesWritten(num) => { + trace!("LinesWritten={num}"); + let sb = buffer_arc.read().await; + let last_line_idx = sb.view_start.saturating_sub(1); + let range = { + let end = if last_idx+num > last_line_idx { + last_line_idx + } else { + last_idx+num + }; + + let r = if last_idx == 0 { + last_idx..=end + } else { + last_idx + 1..=end + }; + + trace!( + %last_idx, + view_start=%sb.view_start, + %last_line_idx, + ?r + ); + + last_idx += num; + r + }; + + for idx in range { + if let Some(line) = sb.lines.get(idx as usize) { + file.write_all(&line.ascii_bytes().collect::>()) + .await + .into_diagnostic() + .wrap_err("Failed to write to file")?; + // .inspect(|_| trace!(?line))?; + } + } + } + SerialEvent::Error(e) => { + error!("error: {e}"); + return Err(miette::miette!("File task recieved error: {e}")); + } + SerialEvent::ConnectionClosed => { + let sb = buffer_arc.read().await; + let viewport = sb.buff_rect(); + trace!(range=?sb.view_start..=(viewport.height + sb.view_start), "flushing"); + for idx in sb.view_start..(viewport.height + sb.view_start) { + if let Some(line) = sb.lines.get(idx as usize) { + file.write_all(&line.ascii_bytes().collect::>()) + .await + .into_diagnostic() + .wrap_err("Failed to write to file")?; + } + } + file.flush() + .await + .into_diagnostic() + .wrap_err("Failed to flush to file")?; + trace!("connection closed"); + break; + } + _ => {} + } + } + Ok(()) } - SerialEvent::ConnectionClosed => { - tracing::info!(target: "session::parse_task", "Connection closed"); - break; + .instrument(span), + ); + + Ok(()) + } + + /// Spawn the parsing task. + /// + /// This task is responsible for parsing the session's incoming data and + /// writing it to the sessions [`ScreenBuffer`]. + #[tracing::instrument(skip_all, level = "debug", name = "parse_task")] + pub(super) fn spawn_parse_task(&mut self) { + use crate::screen::{ByteParser, ScreenDriver}; + + let mut parser = ByteParser::new(); + let buffer = Arc::clone(&self.buffer); + let (events_tx, mut events_rx) = (self.events.clone(), self.events.subscribe()); + + let span = tracing::Span::current(); + self.tasks.spawn( + async move { + while let Ok(event) = events_rx.recv().await { + match event { + SerialEvent::Data(bytes) => { + trace!("received {} bytes", bytes.len()); + let parsed = parser.feed(&bytes); + let mut buf = buffer.write().await; + + let num_lines = ScreenDriver::new(&mut buf).process_events(parsed); + drop(buf); + let _ = events_tx.send(SerialEvent::LinesWritten(num_lines)); + } + SerialEvent::ConnectionClosed => { + trace!("connection closed"); + break; + } + SerialEvent::Error(e) => { + error!("error: {e}"); + break; + } + _ => {} + } + } + + Ok(()) } - _ => unreachable!("SerialActor never sends a SerialEvent::Error"), + .instrument(span), + ); + } +} + +#[cfg(test)] +mod tests { + use std::{ + env::{current_dir, temp_dir}, + fs::{File, remove_file}, + io::{BufRead, BufReader, Write}, + path::PathBuf, + time::Duration, + }; + use tokio::time::sleep; + + use super::super::*; + use test_sericom::get_pts_pair; + + #[tokio::test] + // #[test_log::test] + async fn write_a_file() { + crate::configs::init_for_tests(); + + let bytes = include_bytes!("./mod.rs"); + let tmp_dir = temp_dir(); + let tmp_path = tmp_dir.join("sericom_core_test_write_a_file.txt"); + let og_path = current_dir().unwrap().join("src/session/mod.rs"); + + let mut manager = SessionManager::new(); + let (mut master, slave) = get_pts_pair().unwrap(); + + let id = manager + .spawn( + PathBuf::from(slave), + 9600, + Some(Some(tmp_path.clone())), + true, + ) + .unwrap(); + + sleep(Duration::from_millis(500)).await; + master.write_all(bytes).unwrap(); + sleep(Duration::from_millis(500)).await; + + manager.kill(id).await; + + let out_file = BufReader::new(File::open(&tmp_path).unwrap()); + let og_file = BufReader::new(File::open(&og_path).unwrap()); + let out_lines = out_file.lines().map(Result::unwrap); + let og_lines = og_file.lines().map(Result::unwrap); + + for (og_line, out_line) in og_lines.zip(out_lines) { + assert_eq!(og_line, out_line); } + + let _ = remove_file(tmp_path); } } diff --git a/sericom-core/src/session/mod.rs b/sericom-core/src/session/mod.rs index bb9808e..ed3161a 100644 --- a/sericom-core/src/session/mod.rs +++ b/sericom-core/src/session/mod.rs @@ -1,5 +1,6 @@ mod handle; pub use handle::SessionHandle; +use tracing::{info, warn}; pub type SessionID = u8; @@ -14,7 +15,7 @@ pub struct SessionManager { #[derive(Debug, Clone)] pub struct SessionMeta { pub(crate) baud: u32, - pub(crate) port: String, + pub(crate) port: std::path::PathBuf, } impl SessionManager { @@ -37,6 +38,11 @@ impl SessionManager { self.handles.get(id as usize) } + #[must_use] + pub fn get_mut_session(&mut self, id: SessionID) -> Option<&mut SessionHandle> { + self.handles.get_mut(id as usize) + } + /// Get a reference to the [`SessionMeta`] of [`SessionID`]. /// /// Returns `None` if the [`SessionID`] is invalid. @@ -53,19 +59,23 @@ impl SessionManager { /// TODO: HANDLE ERROR PROPAGATING TO STDOUT #[allow(clippy::result_unit_err)] #[allow(clippy::cast_possible_truncation)] - pub fn spawn(&mut self, port: &str, baud: u32) -> miette::Result { + pub fn spawn( + &mut self, + port: std::path::PathBuf, + baud: u32, + f_path: Option>, + headless: bool, + ) -> miette::Result { let id = self.metas.len(); if !id < u8::MAX as usize { - tracing::warn!(target: "session::spawn", %id, "max sessions reached"); - return Err(()).map_err(|_| miette::miette!("Max sessions reached"))?; // TODO: HANDLE STDOUT ERROR PROPAGATING + warn!(%id, "max sessions reached"); + return Err(miette::miette!("Max sessions reached"))?; } - let meta = SessionMeta { - baud, - port: port.to_owned(), - }; - let handle = SessionHandle::spawn(&meta)?; + let meta = SessionMeta { baud, port }; + let handle = SessionHandle::spawn(&meta, f_path, headless)?; + info!(%id, port=%meta.port.display(), %baud, "created session"); self.handles.push(handle); self.metas.push(meta); @@ -79,17 +89,22 @@ impl SessionManager { pub async fn kill(&mut self, id: SessionID) { let idx = id as usize; if idx >= self.metas.len() { + warn!("invalid session id: '{id}'"); return; } if self.handles.get(idx).is_none() { - tracing::debug!(target: "session::kill", "session {id} does not exist"); + warn!("session '{id}' does not exist"); return; } - self.metas.swap_remove(idx); + let meta = self.metas.swap_remove(idx); self.handles.swap_remove(idx).shutdown().await; - tracing::trace!(target: "session::kill", "session {idx} killed"); + info!( + %id, + port=%meta.port.display(), + "terminated session" + ); // NOTE: // Shouldn't need to adjust active idx because when the user is in the @@ -124,7 +139,7 @@ impl SessionManager { { let _ = writeln!(writer, "{:3} {:6} BAUD", "ID", "PORT"); for (id, meta) in self.metas.iter().enumerate() { - let _ = writeln!(writer, "{:<3} {:6} {}", id, meta.port, meta.baud); + let _ = writeln!(writer, "{:<3} {:6} {}", id, meta.port.display(), meta.baud); } } @@ -132,7 +147,7 @@ impl SessionManager { { let _ = writeln!(writer, "{:3} {:14} BAUD", "ID", "PORT"); for (id, meta) in self.metas.iter().enumerate() { - let _ = writeln!(writer, "{:<3} {:14} {}", id, meta.port, meta.baud); + let _ = writeln!(writer, "{:<3} {:14} {}", id, meta.port.display(), meta.baud); } } } @@ -142,11 +157,22 @@ impl SessionManager { for session in self.handles { idx += 1; session.shutdown().await; - tracing::info!( - target: "session", - port = %self.metas[(idx as usize).saturating_sub(1)].port, - "shutdown session: '{idx}'" + info!( + id=%(idx as usize).saturating_sub(1), + port=%self.metas[(idx as usize).saturating_sub(1)].port.display(), + "terminated session" ); } } + + #[cfg(test)] + pub(crate) async fn assert_buf(&self, id: SessionID, f: F) + where + F: FnOnce(&crate::screen::ScreenBuffer), + { + if let Some(session) = self.get_session(id) { + let sb = session.buffer.read().await; + f(&sb); + } + } } diff --git a/sericom/src/main.rs b/sericom/src/main.rs index 5c07976..e604bef 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -55,12 +55,17 @@ struct Repl { #[derive(Subcommand)] enum Commands { /// Connect to a serial port - #[command(visible_aliases = ["c", "con"], long_about = None, help_template = SUBCOMMAND_TEMPLATE)] + #[command( + visible_aliases = ["c", "con"], + long_about = None, + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true + )] Connect { /// The path to a serial port /// /// For Linux/MacOS something like `/dev/ttyUSB0`, Windows `COM1`. - port: String, + port: PathBuf, /// Baud rate for the serial connection #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] baud: u32, @@ -74,12 +79,23 @@ enum Commands { config_override: ConfigOverrides, }, /// Close a session - #[command(visible_alias = "k", help_template = SUBCOMMAND_TEMPLATE)] + #[command( + visible_alias = "k", + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true + )] Kill { + /// A space-delimited list of sessions to terminate + /// + /// For example: `kill 0 2 3` or `kill 0` session: Vec, }, /// List helpful information - #[command(visible_aliases = ["ls","l"], help_template = SUBCOMMAND_TEMPLATE)] + #[command( + visible_aliases = ["ls","l"], + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true + )] List { #[command(subcommand)] cmd: ListCmds, @@ -223,26 +239,14 @@ async fn handle_cmds( Commands::Connect { port, baud, + #[allow(unused)] config_override, file, bg, - } => { - if bg { - return manager.spawn(&port, baud).map(|_| false); - } - - let id = manager.spawn(&port, baud)?; - if let Some(Some(path)) = &file - && path.is_dir() - { - return Err(miette::miette!( - "Could not create file at: '{}' because it is a directory.", - path.display() - )); - } - - Ok(false) - } + } => match (file.as_ref(), bg) { + (Some(_), bg) => manager.spawn(port, baud, file, bg).map(|_| false), + (None, bg) => manager.spawn(port, baud, None, bg).map(|_| false), + }, Commands::Kill { session } => { for id in session { manager.kill(id).await; @@ -324,9 +328,6 @@ fn init_tracing( filter::Targets::new() .with_target("sericom", Level::TRACE) .with_target("sericom_core", Level::TRACE) - .with_target("session", Level::TRACE) - .with_target("repl", Level::TRACE) - .with_target("exit_script", Level::TRACE) .with_default(Level::ERROR), ) .with( diff --git a/test-sericom/Cargo.toml b/test-sericom/Cargo.toml index 3dfc5e8..e3dc3dd 100644 --- a/test-sericom/Cargo.toml +++ b/test-sericom/Cargo.toml @@ -4,3 +4,5 @@ version = "0.1.0" edition = "2024" [dependencies] +libc = "0.2.178" +tracing.workspace = true diff --git a/test-sericom/src/lib.rs b/test-sericom/src/lib.rs new file mode 100644 index 0000000..d7c57dc --- /dev/null +++ b/test-sericom/src/lib.rs @@ -0,0 +1,122 @@ +#[cfg(unix)] +pub use pts::get_pts_pair; + +#[cfg(unix)] +mod pts { + use std::{ffi::CStr, fs::File, os::fd::FromRawFd}; + + // Needed constants (from fcntl.h) + const O_RDWR: libc::c_int = libc::O_RDWR; + const O_NOCTTY: libc::c_int = libc::O_NOCTTY; + + /// # Example + /// ``` + /// let (mut master, slave) = get_pts_pair()?; + /// println!("Got slave: {slave}"); + /// + /// master.write_all(b"Hello from simulated serial!\r\n")?; + /// master.flush()?; + /// ``` + pub fn get_pts_pair() -> std::io::Result<(File, &'static str)> { + use std::io::Error; + + unsafe { + let master_fd: libc::c_int = libc::posix_openpt(O_RDWR | O_NOCTTY); + if master_fd < 0 { + let e = Error::last_os_error(); + tracing::error!("posix_openpt failed: {e}"); + return Err(e); + } + + if libc::grantpt(master_fd) != 0 { + let e = Error::last_os_error(); + tracing::error!("grantpt failed: {e}"); + libc::close(master_fd); + return Err(e); + } + if libc::unlockpt(master_fd) != 0 { + let e = Error::last_os_error(); + tracing::error!("unlockpt failed: {e}"); + libc::close(master_fd); + return Err(e); + } + + let pts_name = libc::ptsname(master_fd); + if pts_name.is_null() { + let e = Error::last_os_error(); + tracing::error!("pts_name failed - is null: {e}"); + libc::close(master_fd); + return Err(e); + } + + let slave_path = CStr::from_ptr(pts_name as *const libc::c_char) + .to_str() + .expect("Failed to convert pts_name to CStr"); + + set_baud_raw(master_fd).inspect_err(|_| { + libc::close(master_fd); + })?; + + let slave_fd = libc::open(pts_name, O_RDWR | O_NOCTTY); + if slave_fd < 0 { + let e = Error::last_os_error(); + tracing::error!("failed to open the slave: {e}"); + libc::close(master_fd); + return Err(e); + } + + set_baud_raw(slave_fd).inspect_err(|_| { + libc::close(slave_fd); + libc::close(master_fd); + })?; + libc::close(slave_fd); + + let master = std::fs::File::from_raw_fd(master_fd); + tracing::debug!(%slave_path); + Ok((master, slave_path)) + } + } + + fn set_baud_raw(fd: libc::c_int) -> std::io::Result<()> { + use std::io::Error; + + let mut tio: libc::termios = unsafe { + let mut tio: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(fd, &mut tio) != 0 { + let e = Error::last_os_error(); + tracing::error!("tcgetattr failed: {e}"); + return Err(e); + } + tio + }; + + // set input/output speeds + unsafe { + if libc::cfsetispeed(&mut tio, libc::B9600) != 0 + || libc::cfsetospeed(&mut tio, libc::B9600) != 0 + { + let e = Error::last_os_error(); + tracing::error!("failed to set input/output speeds: {e}"); + return Err(e); + } + } + + // a minimal “raw” setup: 8 N 1, no echo / canonical + tio.c_iflag = 0; + tio.c_oflag = 0; + tio.c_cflag = libc::CS8 | libc::CREAD | libc::CLOCAL; + tio.c_lflag = 0; + tio.c_cc[libc::VMIN] = 1; + tio.c_cc[libc::VTIME] = 0; + + unsafe { + if libc::tcsetattr(fd, libc::TCSANOW, &tio) != 0 { + let e = Error::last_os_error(); + tracing::error!("tcsetattr failed: {e}"); + return Err(e); + } + } + + Ok(()) + } +} diff --git a/test-sericom/src/main.rs b/test-sericom/src/main.rs deleted file mode 100644 index 612e0fb..0000000 --- a/test-sericom/src/main.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::{ - io::Write, - process::{Child, Command}, - thread, - time::Duration, -}; - -#[cfg(unix)] -mod pts; -#[cfg(unix)] -use pts::get_pts_pair; - -fn main() -> std::io::Result<()> { - #[cfg(unix)] - { - let (mut master, slave) = get_pts_pair()?; - println!("Got slave: {slave}"); - - let mut seri_guard = ChildGuard { - child: Some( - Command::new("/home/thomas/.cargo/bin/sericom") - .arg(slave) - .spawn() - .expect("Failed to start 'sericom'"), - ), - }; - - master.write_all(b"Hello from simulated serial!\r\n")?; - master.flush()?; - - thread::sleep(Duration::from_secs(2)); - - master.write_all(b"\x1B[2J")?; - master.write_all(b"\x1B[H")?; - master.write_all(b"Ready>\r\n")?; - master.flush()?; - - thread::sleep(Duration::from_secs(5)); - seri_guard.wait(); - } - Ok(()) -} - -struct ChildGuard { - child: Option, -} - -impl ChildGuard { - fn wait(&mut self) { - if let Some(mut c) = self.child.take() { - let _ = c.wait(); - } - } -} - -impl Drop for ChildGuard { - fn drop(&mut self) { - if let Some(mut c) = self.child.take() { - let _ = c.kill(); - let _ = c.wait(); - } - } -} - -#[test] -fn test_sub() { - let seq = [0x1B, b'[', b'6', b'n']; - let body = &seq[2..seq.len() - 1]; - - println!("{:?}", body); - assert_eq!(body, [b'6']); - // assert!(body.is_empty()); - assert!(body.len() == 1); -} diff --git a/test-sericom/src/pts.rs b/test-sericom/src/pts.rs deleted file mode 100644 index 9295ec5..0000000 --- a/test-sericom/src/pts.rs +++ /dev/null @@ -1,47 +0,0 @@ -use std::{ffi::CStr, fs::File, os::fd::FromRawFd}; - -#[allow(non_camel_case_types)] -type c_int = i32; -#[allow(non_camel_case_types)] -type c_char = i8; - -// Declare external C functions from and -unsafe extern "C" { - fn posix_openpt(flags: c_int) -> c_int; - fn grantpt(fd: c_int) -> c_int; - fn unlockpt(fd: c_int) -> c_int; - fn ptsname(fd: c_int) -> *mut c_char; -} - -// Needed constants (from fcntl.h) -const O_RDWR: i32 = 0o00000002; -const O_NOCTTY: i32 = 0o00000400; - -pub fn get_pts_pair() -> std::io::Result<(File, &'static str)> { - use std::io::Error; - unsafe { - let master_fd: c_int = posix_openpt(O_RDWR | O_NOCTTY); - if master_fd < 0 { - panic!("posix_openpt failed: {}", Error::last_os_error()); - } - - if grantpt(master_fd) != 0 { - panic!("grantpt failed: {}", Error::last_os_error()); - } - if unlockpt(master_fd) != 0 { - panic!("unlock failed: {}", Error::last_os_error()); - } - - let pts_name = ptsname(master_fd); - if pts_name.is_null() { - panic!("pts_name failed - is null"); - } - - let slave_path = CStr::from_ptr(pts_name as *const c_char) - .to_str() - .expect("Failed to convert pts_name to CStr"); - - let master = std::fs::File::from_raw_fd(master_fd); - Ok((master, slave_path)) - } -} From b78d60419e32db6c3a52a562d9388a99c6f9d696 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Mon, 15 Dec 2025 10:59:29 -0600 Subject: [PATCH 35/40] refactor(tasks,error): Refactored session tasks and error handling changelog: ignore --- Cargo.lock | 16 +- Cargo.toml | 4 +- sericom-core/Cargo.toml | 3 +- sericom-core/src/cli.rs | 25 +- sericom-core/src/lib.rs | 2 + sericom-core/src/screen/driver.rs | 5 +- sericom-core/src/serial_actor/mod.rs | 35 ++- sericom-core/src/session/error.rs | 160 ++++++++++++ sericom-core/src/session/handle.rs | 366 +++++++++++++++------------ sericom-core/src/session/mod.rs | 144 ++++++++++- sericom/Cargo.toml | 2 +- sericom/src/main.rs | 276 +------------------- sericom/src/repl.rs | 285 +++++++++++++++++++++ 13 files changed, 840 insertions(+), 483 deletions(-) create mode 100644 sericom-core/src/session/error.rs create mode 100644 sericom/src/repl.rs diff --git a/Cargo.lock b/Cargo.lock index e9417b9..9965181 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,11 +508,10 @@ checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] @@ -662,9 +661,9 @@ checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -672,15 +671,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.0", ] [[package]] @@ -900,6 +899,7 @@ dependencies = [ "chrono", "crossterm", "miette", + "parking_lot", "serde", "serial2-tokio", "test-log", diff --git a/Cargo.toml b/Cargo.toml index 8013c52..9277ea6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,9 @@ repository = "https://github.com/tkatter/sericom" [workspace.dependencies] chrono = "0.4.42" crossterm = { version = "0.29.0", features = ["osc52", "event-stream"] } -miette = { version = "7.6.0", features = ["fancy"] } -serial2-tokio = { version = "0.1.19", features = ["windows"] } +miette = "7.6.0" tokio = { version = "1.48.0", features = ["full"] } +serial2-tokio = "0.1.19" tracing = "0.1.43" [patch.crates-io] diff --git a/sericom-core/Cargo.toml b/sericom-core/Cargo.toml index 3425e2d..719358e 100644 --- a/sericom-core/Cargo.toml +++ b/sericom-core/Cargo.toml @@ -9,13 +9,14 @@ exclude.workspace = true repository.workspace = true [dependencies] +parking_lot = "0.12.5" serde = { version = "1.0.228", features = ["derive"] } thiserror = "2.0.17" toml = "0.9.8" chrono.workspace = true crossterm.workspace = true miette.workspace = true -serial2-tokio.workspace = true +serial2-tokio = { workspace = true, features = ["windows"] } tokio.workspace = true tracing = { workspace = true, features = ["attributes"] } diff --git a/sericom-core/src/cli.rs b/sericom-core/src/cli.rs index a88f167..69e7e33 100644 --- a/sericom-core/src/cli.rs +++ b/sericom-core/src/cli.rs @@ -1,19 +1,15 @@ //! This module holds the functions that are called from `sericom` when receiving //! CLI commands/arguments. -// use crossterm::event; use serial2_tokio::SerialPort; -use std::{ - // io::{self, Write}, - path::PathBuf, -}; +use std::path::PathBuf; -use crate::map_miette; +use crate::{SeriError, err_into, map_miette}; /// Opens a serial `port` for communication with the specified `baud`. /// /// Returns `Ok(SerialPort)` or errors if unable to set the baud rate or open the `port`. -pub fn open_connection(baud: u32, port: &PathBuf) -> miette::Result { +pub fn open_connection(baud: u32, port: &PathBuf) -> crate::Result { let settings = |mut s: serial2_tokio::Settings| -> std::io::Result { s.set_raw(); s.set_baud_rate(baud)?; @@ -23,11 +19,9 @@ pub fn open_connection(baud: u32, port: &PathBuf) -> miette::Result s.set_flow_control(serial2_tokio::FlowControl::None); Ok(s) }; - let con = map_miette!( - SerialPort::open(port, settings), - format!("Failed to open port '{}'", port.display()), - help = "Is the port already open?\nTo see available ports, try `list ports`." - )?; + + let con = SerialPort::open(port, settings).map_err(|e| err_into!(e, SeriError::Connection))?; + Ok(con) } @@ -95,11 +89,8 @@ pub fn get_settings(baud: u32, port: &PathBuf) -> miette::Result<()> { /// /// Ultimately a wrapper around [`SerialPort::available_ports()`] and may error /// if it is called on an unsupported platform as per [`SerialPort::available_ports()`]s docs -pub fn list_serial_ports() -> miette::Result<()> { - let ports = map_miette!( - SerialPort::available_ports(), - "Could not list available ports." - )?; +pub fn list_serial_ports() -> crate::Result<()> { + let ports = SerialPort::available_ports()?; for path in ports { println!("{}", path.display()); diff --git a/sericom-core/src/lib.rs b/sericom-core/src/lib.rs index 50ddaa8..39d24d9 100644 --- a/sericom-core/src/lib.rs +++ b/sericom-core/src/lib.rs @@ -19,3 +19,5 @@ pub mod screen; pub mod serial_actor; pub mod session; pub mod ui; +pub use session::SeriError; +pub type Result = std::result::Result; diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/driver.rs index f749c2b..d60b987 100644 --- a/sericom-core/src/screen/driver.rs +++ b/sericom-core/src/screen/driver.rs @@ -124,7 +124,10 @@ impl<'a> ScreenDriver<'a> { self.buffer .handle_span_colors(&self.color_state, self.attrs); } - EscSequenceType::Screen(_kind) => { /* process_screen(seq, kind, self.buffer) */ } + EscSequenceType::Screen(kind) => { + tracing::debug!("EscSequenceType::Screen unimplemented, got: {:X?}", kind); + /* process_screen(seq, kind, self.buffer) */ + } } } } diff --git a/sericom-core/src/serial_actor/mod.rs b/sericom-core/src/serial_actor/mod.rs index 7c997f6..b4dee7e 100644 --- a/sericom-core/src/serial_actor/mod.rs +++ b/sericom-core/src/serial_actor/mod.rs @@ -3,6 +3,8 @@ pub mod tasks; +use crate::SeriError; + /// Represents messages/commands that are sent from worker tasks to the [`SerialActor`] to process. #[non_exhaustive] #[derive(Debug)] @@ -23,7 +25,7 @@ pub enum SerialEvent { /// Sends data received by the [`SerialActor`] to its tasks. Data(std::sync::Arc<[u8]>), /// Sends the error message received by the [`SerialActor`] to its tasks to handle. - Error(String), + Error(SeriError), /// Tells the [`SerialActor`]s tasks that the serial connection has been closed. /// /// This serves a different purpose from [`SerialMessage::Shutdown`] where @@ -45,7 +47,7 @@ pub enum SerialEvent { pub struct SerialActor { connection: serial2_tokio::SerialPort, command_rx: tokio::sync::mpsc::Receiver, - broadcast_channel: tokio::sync::broadcast::Sender, + tasks_broadcast: tokio::sync::broadcast::Sender, } impl SerialActor { @@ -55,12 +57,12 @@ impl SerialActor { pub const fn new( connection: serial2_tokio::SerialPort, command_rx: tokio::sync::mpsc::Receiver, - broadcast_channel: tokio::sync::broadcast::Sender, + tasks_broadcast: tokio::sync::broadcast::Sender, ) -> Self { Self { connection, command_rx, - broadcast_channel, + tasks_broadcast, } } @@ -74,7 +76,7 @@ impl SerialActor { /// Since data is sent byte-by-byte over a serial connection, `run` will /// batch the data before sending it to other tasks to reduce the number of syscalls. #[tracing::instrument(skip_all, level = "debug")] - pub async fn run(mut self) -> miette::Result<()> { + pub async fn run(mut self) -> crate::Result<()> { use tracing::{debug, error, trace}; trace!("running SerialActor"); @@ -88,21 +90,21 @@ impl SerialActor { Some(SerialMessage::Write(data)) => { if let Err(e) = self.connection.write_all(&data).await { error!("error writing to connection: {e}"); - self.broadcast_channel.send(SerialEvent::Error(e.to_string())).ok(); + return Err(crate::SeriError::from(e).add_ctx("Error writing to the connection".into())); } } Some(SerialMessage::Shutdown) => { debug!("recieved shutdown command"); - self.broadcast_channel.send(SerialEvent::ConnectionClosed).ok(); - break; + self.tasks_broadcast.send(SerialEvent::ConnectionClosed).ok(); + return Ok(()); } Some(SerialMessage::SendBreak) => { debug!("sending break signal"); self.send_break().await; } None => { - debug!("command_rx.recv() was None"); - break; + trace!("command_rx.recv() was None"); + return Err(SeriError::TaskError("No active tasks, shutting down.".into())); } } } @@ -111,25 +113,22 @@ impl SerialActor { match read_result { Ok(0) => { debug!("closing connection - no bytes read"); - self.broadcast_channel.send(SerialEvent::ConnectionClosed).ok(); - break; + self.tasks_broadcast.send(SerialEvent::ConnectionClosed).ok(); + return Ok(()); } Ok(n) => { let data: std::sync::Arc<[u8]> = buffer[..n].into(); - debug!("recieved {} bytes", data.len()); - self.broadcast_channel.send(SerialEvent::Data(data)).ok(); + trace!("recieved {} bytes", data.len()); + self.tasks_broadcast.send(SerialEvent::Data(data)).ok(); } Err(e) => { error!("error reading from connection: {e}"); - self.broadcast_channel.send(SerialEvent::Error(e.to_string())).ok(); - break; + return Err(SeriError::from(e).add_ctx("Error reading from connection".into())); } } } } } - - Ok(()) } async fn send_break(&self) { diff --git a/sericom-core/src/session/error.rs b/sericom-core/src/session/error.rs new file mode 100644 index 0000000..8c58f23 --- /dev/null +++ b/sericom-core/src/session/error.rs @@ -0,0 +1,160 @@ +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Debug, Clone, Diagnostic, Error)] +pub enum SeriError { + #[error("Task error: {0:?}")] + TaskError(String), + #[error("{}", ctx.as_ref().map_or("I/O Error", |c| c))] + Io { + #[source] + source: std::sync::Arc, + #[help] + help: Option, + ctx: Option, + }, + #[error("Serial connection error")] + Connection { + #[source] + source: std::sync::Arc, + #[help] + help: Option, + }, +} + +#[allow(unused)] +impl SeriError { + #[must_use] + pub fn add_ctx(mut self, msg: String) -> Self { + match &mut self { + Self::TaskError(_) => todo!(), + Self::Io { source, help, ctx } => *ctx = Some(msg), + Self::Connection { source, help } => todo!(), + } + + self + } + + #[must_use] + pub fn add_help(mut self, msg: String) -> Self { + match &mut self { + Self::TaskError(_) => todo!(), + Self::Io { source, help, ctx } => *help = Some(msg), + Self::Connection { source, help } => *help = Some(msg), + } + + self + } +} + +impl From for SeriError { + fn from(err: std::io::Error) -> Self { + Self::Io { + source: std::sync::Arc::new(err), + help: None, + ctx: None, + } + } +} + +#[macro_export] +macro_rules! err_into { + ($err:expr, $variant:path) => {{ + $variant { + source: std::sync::Arc::new($err), + help: None, + } + }}; + + ($err:expr, $variant:path, help = $help:expr) => {{ + $variant { + source: $err, + help: $help, + } + }}; + + ($err:expr, $variant:path, help = $help:expr) => {{ + $variant { + source: $err, + help: $help, + } + }}; +} + +#[macro_export] +macro_rules! with_help { + ($err:expr, $msg:literal) => {{ + match $err { + $crate::SeriError::Io { source, .. } => { + $crate::SeriError::Io { + source, + help: Some($msg.into()), + ctx: None, + } + } + $crate::SeriError::Connection { source, .. } => { + $crate::SeriError::Connection { + source, + help: Some($msg.into()), + } + } + _ => $err, + } + }}; + ($err:expr, $msg:literal , $($args:expr),* ) => {{ + match $err { + $crate::SeriError::Io { source, .. } => { + $crate::SeriError::Io { + source, + help: Some(format!($msg, $($args),*)), + ctx: None, + } + } + $crate::SeriError::Connection { source, .. } => { + $crate::SeriError::Connection { + source, + help: Some(format!($msg, $($args),*)), + } + } + _ => $err, + } + }}; + ($var:path [ $err:expr ] : $msg:literal , $($args:expr),*) => {{ + $var { + source: $err, + help: Some(format!($msg, $($args),*)), + } + }}; + + ($var:path [ $err:expr ] : $msg:literal $($more_msg:literal)* , $($args:expr),*) => {{ + $var { + source: $err, + help: Some(format!(concat!($msg, $("\n", $more_msg),+), $($args),*)), + } + }}; +} + +#[test] +fn test_macro() { + fn err() -> miette::Result<()> { + let e = std::io::Error::last_os_error(); + // let err: SeriError = with_help!( + // SeriError::ConnectionIo[e]: "hello {} {} {}" + // "this is another line {}" + // "and another line {}", + // "world", 1, 2, 3, 4 + // ); + // let err: SeriError = with_help!( + // SeriError::ConnectionIo[e]: "hello {} {} {}", 1, 2, 3 + // ); + let err = SeriError::Io { + source: std::sync::Arc::new(e), + help: None, + ctx: None, + }; + + Err(err).map_err(|e| with_help!(e, "hello {} {} {}", 1, 2, 3).into()) + } + + err().inspect_err(|err| eprintln!("{err:?}")).ok(); +} diff --git a/sericom-core/src/session/handle.rs b/sericom-core/src/session/handle.rs index 2904a17..7323d74 100644 --- a/sericom-core/src/session/handle.rs +++ b/sericom-core/src/session/handle.rs @@ -1,15 +1,17 @@ -use miette::{Context, IntoDiagnostic}; +use miette::Context; use std::{path::PathBuf, sync::Arc}; -use tokio::{io::AsyncWriteExt, task::JoinSet}; -use tracing::{Instrument, debug, error, info, trace}; +use tokio::{io::AsyncWriteExt, sync::oneshot, task::JoinSet}; +use tracing::{Instrument, Span, debug, error, info, trace, warn}; use crate::{ + SeriError, cli::open_connection, compat_port_path, configs::get_config, create_recursive, screen::{Rect, ScreenBuffer}, serial_actor::{SerialActor, SerialEvent, SerialMessage}, + with_help, }; /// A handle to a session - used to manage the session in headless/interactive modes. @@ -34,14 +36,34 @@ pub struct SessionHandle { /// /// [`Receiver`]: tokio::sync::broadcast::Receiver /// [`SessionManager`]: super::SessionManager - pub(crate) events: tokio::sync::broadcast::Sender, + pub(crate) events_tx: tokio::sync::broadcast::Sender, /// A sessions async tasks. /// /// Currently a session only has two tasks, the [`SerialActor::run`] and /// the task responsible for parsing the session's incoming data/byte stream. /// The [`JoinSet`] is used so that when a session is terminated, all tasks /// associated with a session can be gracefully killed together. - pub(crate) tasks: JoinSet>, + pub(crate) tasks: JoinSet>, +} + +async fn instrument_task( + fut: F, + span: Option, + events: tokio::sync::broadcast::Sender, +) -> crate::Result<()> +where + F: Future> + Send + 'static, +{ + let res = if let Some(span) = span { + fut.instrument(span).await + } else { + fut.await + }; + + if let Err(ref e) = res { + let _ = events.send(SerialEvent::Error(e.clone())); + } + res } impl SessionHandle { @@ -50,17 +72,19 @@ impl SessionHandle { meta: &super::SessionMeta, with_file: Option>, headless: bool, + mgr_tx: oneshot::Sender, ) -> miette::Result { let (tx, rx) = tokio::sync::mpsc::channel::(100); let (events_tx, _) = tokio::sync::broadcast::channel::(128); - let connection = open_connection(meta.baud, &meta.port)?; + let connection = open_connection(meta.baud, &meta.port) + .map_err(|e| with_help!(e, "Is the port already open? Do you have permission? Typo?")) + .wrap_err(format!("Failed to open port: '{}'", meta.port.display()))?; let (term_w, term_h) = if headless { #[cfg(not(test))] { (80, 24) } - #[cfg(test)] { (120, 10) @@ -78,20 +102,18 @@ impl SessionHandle { let dbg = tracing::debug_span!("session", port = %meta.port.display()); if dbg.is_disabled() { info } else { dbg } }; - - { - let _enter = span.enter(); - debug!(port=%meta.port.display(), baud=%meta.baud, "opened connection"); - } + let _enter = span.enter(); + debug!(port=%meta.port.display(), baud=%meta.baud, "opened connection"); let mut handle = Self { buffer, tx, - events: events_tx, + events_tx, tasks: JoinSet::new(), }; - handle.tasks.spawn(actor.run().instrument(span.clone())); - span.in_scope(|| handle.spawn_parse_task()); + handle.spawn_monitor_task(mgr_tx); + handle.spawn_actor_task(actor); + handle.spawn_parse_task(); if with_file.is_some() { let config = get_config()?; @@ -113,7 +135,7 @@ impl SessionHandle { drop(config); compat_port_path!(default_out_dir, &meta.port) }; - span.in_scope(|| handle.spawn_file_task(&file_path))?; + handle.spawn_file_task(file_path); } Ok(handle) @@ -124,178 +146,210 @@ impl SessionHandle { /// Signals associated tasks to shutdown and waits for them to complete. pub async fn shutdown(self) { if let Err(e) = self.tx.send(SerialMessage::Shutdown).await { - debug!("error sending shutdown to actor: {e}"); + warn!("error sending shutdown to actor: {e}"); let mut tasks = self.tasks; tasks.abort_all(); - return; + } else { + self.tasks.join_all().await; } - self.tasks.join_all().await; trace!("all tasks finished, shutting down"); } - /// Spawns a task that writes the session's output to a file. - #[tracing::instrument(skip_all, name = "file_task")] - pub fn spawn_file_task(&mut self, f_path: &PathBuf) -> miette::Result<()> { - use std::fs::File; - use tokio::fs; - - // Creating the file outside of the task to propogate errors - // since SessionHandle doesn't have a good way to listen for task - // errors and act accordingly - TODO - let file = File::create(f_path) - .into_diagnostic() - .inspect_err(|_| { - error!("failed to create file: {}", f_path.display()); - }) - .wrap_err(format!("Failed to create file: {}", f_path.display()))?; - - info!(file=%f_path.display(), "created file"); - - let mut events_rx = self.events.subscribe(); - let buffer_arc = Arc::clone(&self.buffer); - + /// Monitors the tasks' broadcast channel for ones that send an error. + /// + /// Upon receiving an error, propogates to the [`SessionManager`] to handle. + /// + /// [`SessionManager`]: super::SessionManager + fn spawn_monitor_task(&mut self, mgr_tx: oneshot::Sender) { + let (tasks_tx, mut mon_rx) = (self.tx.clone(), self.events_tx.subscribe()); let span = tracing::Span::current(); self.tasks.spawn( async move { - let mut file = fs::File::from_std(file); - let mut last_idx = buffer_arc.read().await.view_start; - - while let Ok(event) = events_rx.recv().await { + while let Ok(event) = mon_rx.recv().await { match event { - SerialEvent::LinesWritten(0) => {} - SerialEvent::LinesWritten(1) => { - trace!("LinesWritten=1"); - let sb = buffer_arc.read().await; - if sb.view_start.saturating_sub(1) == last_idx { - continue; - } - last_idx += 1; - - trace!(%last_idx, view_start=%sb.view_start); - - if let Some(line) = sb.lines.get(last_idx as usize) { - file.write_all(&line.ascii_bytes().collect::>()) - .await - .into_diagnostic() - .wrap_err("Failed to write to file")?; - } - drop(sb); - } - SerialEvent::LinesWritten(num) => { - trace!("LinesWritten={num}"); - let sb = buffer_arc.read().await; - let last_line_idx = sb.view_start.saturating_sub(1); - let range = { - let end = if last_idx+num > last_line_idx { - last_line_idx - } else { - last_idx+num - }; - - let r = if last_idx == 0 { - last_idx..=end - } else { - last_idx + 1..=end - }; - - trace!( - %last_idx, - view_start=%sb.view_start, - %last_line_idx, - ?r - ); - - last_idx += num; - r - }; - - for idx in range { - if let Some(line) = sb.lines.get(idx as usize) { - file.write_all(&line.ascii_bytes().collect::>()) - .await - .into_diagnostic() - .wrap_err("Failed to write to file")?; - // .inspect(|_| trace!(?line))?; - } - } - } SerialEvent::Error(e) => { - error!("error: {e}"); - return Err(miette::miette!("File task recieved error: {e}")); - } - SerialEvent::ConnectionClosed => { - let sb = buffer_arc.read().await; - let viewport = sb.buff_rect(); - trace!(range=?sb.view_start..=(viewport.height + sb.view_start), "flushing"); - for idx in sb.view_start..(viewport.height + sb.view_start) { - if let Some(line) = sb.lines.get(idx as usize) { - file.write_all(&line.ascii_bytes().collect::>()) - .await - .into_diagnostic() - .wrap_err("Failed to write to file")?; - } - } - file.flush() - .await - .into_diagnostic() - .wrap_err("Failed to flush to file")?; - trace!("connection closed"); - break; + error!(err=%e, "task failed"); + let _ = mgr_tx.send(e); + let _ = tasks_tx.send(SerialMessage::Shutdown).await; + break; // so compiler doesn't complain about mgr_tx being moved } + SerialEvent::ConnectionClosed => break, _ => {} } } + Ok(()) } .instrument(span), ); + } + + /// Spawns a task that runs the [`SerialActor`] for the session. + fn spawn_actor_task(&mut self, actor: SerialActor) { + self.tasks.spawn(instrument_task( + actor.run(), + Some(Span::current()), + self.events_tx.clone(), + )); + } - Ok(()) + /// Spawns a task that writes the session's output to a file. + fn spawn_file_task(&mut self, f_path: PathBuf) { + self.tasks.spawn(instrument_task( + file_task(f_path, Arc::clone(&self.buffer), self.events_tx.subscribe()), + Some(Span::current()), + self.events_tx.clone(), + )); } /// Spawn the parsing task. /// /// This task is responsible for parsing the session's incoming data and /// writing it to the sessions [`ScreenBuffer`]. - #[tracing::instrument(skip_all, level = "debug", name = "parse_task")] - pub(super) fn spawn_parse_task(&mut self) { - use crate::screen::{ByteParser, ScreenDriver}; + fn spawn_parse_task(&mut self) { + self.tasks.spawn(instrument_task( + parse_task( + Arc::clone(&self.buffer), + self.events_tx.clone(), + self.events_tx.subscribe(), + ), + Some(Span::current()), + self.events_tx.clone(), + )); + } +} - let mut parser = ByteParser::new(); - let buffer = Arc::clone(&self.buffer); - let (events_tx, mut events_rx) = (self.events.clone(), self.events.subscribe()); +#[tracing::instrument(skip_all)] +async fn file_task( + f_path: PathBuf, + buffer: Arc>, + mut events_rx: tokio::sync::broadcast::Receiver, +) -> crate::Result<()> { + let mut file = tokio::fs::File::create(&f_path) + .await + .inspect_err(|_| { + error!("failed to create file: {}", f_path.display()); + }) + .map_err(|e| { + SeriError::from(e).add_ctx(format!("Failed to create file: {}", f_path.display())) + })?; + + info!(file=%f_path.display(), "created file"); + let mut last_idx = buffer.read().await.view_start; + + while let Ok(event) = events_rx.recv().await { + match event { + SerialEvent::LinesWritten(0) => {} + SerialEvent::LinesWritten(1) => { + trace!("LinesWritten=1"); + let sb = buffer.read().await; + if sb.view_start.saturating_sub(1) == last_idx { + continue; + } + last_idx += 1; - let span = tracing::Span::current(); - self.tasks.spawn( - async move { - while let Ok(event) = events_rx.recv().await { - match event { - SerialEvent::Data(bytes) => { - trace!("received {} bytes", bytes.len()); - let parsed = parser.feed(&bytes); - let mut buf = buffer.write().await; - - let num_lines = ScreenDriver::new(&mut buf).process_events(parsed); - drop(buf); - let _ = events_tx.send(SerialEvent::LinesWritten(num_lines)); - } - SerialEvent::ConnectionClosed => { - trace!("connection closed"); - break; - } - SerialEvent::Error(e) => { - error!("error: {e}"); - break; - } - _ => {} + trace!(%last_idx, view_start=%sb.view_start); + + if let Some(line) = sb.lines.get(last_idx as usize) { + file.write_all(&line.ascii_bytes().collect::>()) + .await + .map_err(|e| { + SeriError::from(e).add_ctx("Failed to write to file".into()) + })?; + } + drop(sb); + } + SerialEvent::LinesWritten(num) => { + trace!("LinesWritten={num}"); + let sb = buffer.read().await; + let last_line_idx = sb.view_start.saturating_sub(1); + let range = { + let end = if last_idx + num > last_line_idx { + last_line_idx + } else { + last_idx + num + }; + + let r = if last_idx == 0 { + last_idx..=end + } else { + last_idx + 1..=end + }; + + trace!( + %last_idx, + view_start=%sb.view_start, + %last_line_idx, + ?r + ); + + last_idx += num; + r + }; + + for idx in range { + if let Some(line) = sb.lines.get(idx as usize) { + file.write_all(&line.ascii_bytes().collect::>()) + .await + .map_err(|e| { + SeriError::from(e).add_ctx("Failed to write to file".into()) + })?; } } + } + SerialEvent::ConnectionClosed => { + let sb = buffer.read().await; + let viewport = sb.buff_rect(); + trace!(range=?sb.view_start..=(viewport.height + sb.view_start), "flushing"); + for idx in sb.view_start..(viewport.height + sb.view_start) { + if let Some(line) = sb.lines.get(idx as usize) { + file.write_all(&line.ascii_bytes().collect::>()) + .await + .map_err(|e| { + SeriError::from(e).add_ctx("Failed to write to file".into()) + })?; + } + } + file.flush() + .await + .map_err(|e| SeriError::from(e).add_ctx("Failed to flush to file".into()))?; + trace!("connection closed"); + break; + } + _ => {} + } + } + Ok(()) +} - Ok(()) +#[tracing::instrument(skip_all, level = "debug")] +async fn parse_task( + buffer: Arc>, + events_tx: tokio::sync::broadcast::Sender, + mut events_rx: tokio::sync::broadcast::Receiver, +) -> crate::Result<()> { + use crate::screen::{ByteParser, ScreenDriver}; + + let mut parser = ByteParser::new(); + while let Ok(event) = events_rx.recv().await { + match event { + SerialEvent::Data(bytes) => { + trace!("received {} bytes", bytes.len()); + let parsed = parser.feed(&bytes); + let mut buf = buffer.write().await; + + let num_lines = ScreenDriver::new(&mut buf).process_events(parsed); + drop(buf); + let _ = events_tx.send(SerialEvent::LinesWritten(num_lines)); } - .instrument(span), - ); + SerialEvent::ConnectionClosed => { + trace!("connection closed"); + break; + } + _ => {} + } } + Ok(()) } #[cfg(test)] @@ -313,7 +367,7 @@ mod tests { use test_sericom::get_pts_pair; #[tokio::test] - // #[test_log::test] + #[test_log::test] async fn write_a_file() { crate::configs::init_for_tests(); diff --git a/sericom-core/src/session/mod.rs b/sericom-core/src/session/mod.rs index ed3161a..72e339f 100644 --- a/sericom-core/src/session/mod.rs +++ b/sericom-core/src/session/mod.rs @@ -1,14 +1,24 @@ +use std::sync::Arc; +use tokio::{sync::oneshot, task::JoinHandle}; +use tracing::{info, warn}; + +mod error; mod handle; +pub use error::SeriError; pub use handle::SessionHandle; -use tracing::{info, warn}; pub type SessionID = u8; +pub type UpdatedId = (SessionID, SessionID); #[derive(Debug, Default)] pub struct SessionManager { + active: Option, + id_update_tx: tokio::sync::watch::Sender>, handles: Vec, metas: Vec, - active: Option, + monitors: Vec>, + errors: Arc>>, + shutting_down: Arc, } /// Includes the baud rate and name of the port/connection. @@ -21,12 +31,17 @@ pub struct SessionMeta { impl SessionManager { #[must_use] pub fn new() -> Self { + let (id_update_tx, _) = tokio::sync::watch::channel(None); Self { active: None, // Creating with capacity of ~25 because if someone has more than 25 // sessions they can get an allocation - majority will have < 25 sessions handles: Vec::with_capacity((u8::MAX / 10u8) as usize), metas: Vec::with_capacity((u8::MAX / 10u8) as usize), + monitors: Vec::with_capacity((u8::MAX / 10u8) as usize), + errors: Arc::new(parking_lot::Mutex::new(Vec::with_capacity(8))), + id_update_tx, + shutting_down: Arc::new(tokio::sync::Notify::new()), } } @@ -56,7 +71,6 @@ impl SessionManager { /// # Errors /// Errors if there are already a maximum number of sessions ([`u8::MAX`]) or /// if the [`SessionHandle::spawn`] errors. - /// TODO: HANDLE ERROR PROPAGATING TO STDOUT #[allow(clippy::result_unit_err)] #[allow(clippy::cast_possible_truncation)] pub fn spawn( @@ -68,17 +82,19 @@ impl SessionManager { ) -> miette::Result { let id = self.metas.len(); - if !id < u8::MAX as usize { + if id >= u8::MAX as usize { warn!(%id, "max sessions reached"); return Err(miette::miette!("Max sessions reached"))?; } + let (err_tx, err_rx) = oneshot::channel::(); let meta = SessionMeta { baud, port }; - let handle = SessionHandle::spawn(&meta, f_path, headless)?; + let handle = SessionHandle::spawn(&meta, f_path, headless, err_tx)?; info!(%id, port=%meta.port.display(), %baud, "created session"); self.handles.push(handle); self.metas.push(meta); + self.start_monitor(id as SessionID, err_rx); // Cast is fine, verified that id is < u8::MAX Ok(id as SessionID) @@ -100,6 +116,13 @@ impl SessionManager { let meta = self.metas.swap_remove(idx); self.handles.swap_remove(idx).shutdown().await; + + let id_to_update = self.monitors.len().saturating_sub(1) as SessionID; + self.monitors.swap_remove(idx).abort(); + self.id_update_tx + .send(Some((id_to_update, idx as SessionID))) + .ok(); + info!( %id, port=%meta.port.display(), @@ -152,8 +175,22 @@ impl SessionManager { } } + fn start_monitor(&mut self, id: SessionID, err_rx: oneshot::Receiver) { + let mon = tokio::task::spawn(run_monitor( + id, + Arc::clone(&self.shutting_down), + self.id_update_tx.subscribe(), + Arc::clone(&self.errors), + err_rx, + )); + + self.monitors.push(mon); + } + pub async fn graceful_shutdown(self) { let mut idx: u8 = 0; + self.shutting_down.notify_waiters(); + for session in self.handles { idx += 1; session.shutdown().await; @@ -165,7 +202,102 @@ impl SessionManager { } } - #[cfg(test)] + /// Provides access to any errors the [`SessionManager`] has collected. + /// + /// *Note:* If there is an active session, this will simply return without calling + /// the provided function. See [`force_check_errors()`] for unconditional + /// access to errors. + /// + /// [`force_check_errors()`]: SessionManager::force_check_errors + pub async fn check_errors(&mut self, f: F) + where + F: FnOnce(&[(SessionID, miette::Report)]), + { + let drained = { + let mut errors = self.errors.lock(); + if errors.is_empty() || self.active.is_some() { + return; + } + errors + .drain(..) + .collect::>() + }; + + f(&drained); + + for (id, _) in drained { + self.kill(id).await; + } + } + + /// Provides access to any errors the [`SessionManager`] has collected. + /// + /// Unlike [`check_errors()`], this will unconditionally give access to errors, if any. + /// + /// [`check_errors()`]: SessionManager::check_errors + pub async fn force_check_errors(&mut self, f: F) + where + F: FnOnce(&[(SessionID, miette::Report)]), + { + let drained = { + let mut errors = self.errors.lock(); + if errors.is_empty() { + return; + } + errors + .drain(..) + .collect::>() + }; + + f(&drained); + + for (id, _) in drained { + self.kill(id).await; + } + } +} + +async fn run_monitor( + id: SessionID, + shutting_down: Arc, + mut id_updated: tokio::sync::watch::Receiver>, + errors: Arc>>, + err_rx: tokio::sync::oneshot::Receiver, +) { + let mut err_rx = Box::pin(err_rx); + let mut id = id; + + loop { + tokio::select! { + () = shutting_down.notified() => break, + changed = id_updated.changed() => { + match changed { + Ok(()) => { + if let Some(update) = *id_updated.borrow() + && update.0 == id + { + id = update.1; + } + } + Err(_) => break, + } + }, + msg = &mut err_rx => { + match msg { + Ok(err) => { + errors.lock().push((id, miette::Report::from(err))); + return; + } + Err(_) => break, + } + } + } + } +} + +#[cfg(test)] +#[allow(unused)] +impl SessionManager { pub(crate) async fn assert_buf(&self, id: SessionID, f: F) where F: FnOnce(&crate::screen::ScreenBuffer), diff --git a/sericom/Cargo.toml b/sericom/Cargo.toml index 7bb7598..00e8672 100644 --- a/sericom/Cargo.toml +++ b/sericom/Cargo.toml @@ -36,6 +36,6 @@ tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } chrono.workspace = true tokio.workspace = true serial2-tokio.workspace = true -miette.workspace = true +miette = { workspace = true, features = ["fancy"] } crossterm.workspace = true tracing.workspace = true diff --git a/sericom/src/main.rs b/sericom/src/main.rs index e604bef..3917ed5 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -7,151 +7,12 @@ //! scripts is to be able to automate tasks that take place over a serial connection i.e. //! configuration, resetting, getting statistics, etc. -use clap::{CommandFactory, Parser, Subcommand}; use miette::{Context as _, IntoDiagnostic}; -use sericom_core::{ - cli::{color_parser, list_serial_ports, valid_baud_rate}, - configs::{get_config, initialize_config}, - path_utils::{is_script, validate_dir}, -}; +use sericom_core::configs::{get_config, initialize_config}; use std::path::{Path, PathBuf}; -const RED: &str = "\x1b\x5b31m"; -const CYAN: &str = "\x1b\x5b36m"; -const BOLD: &str = "\x1b\x5b1m"; -// const UNBOLD: &str = "\x1b\x5b22m"; -const DIM: &str = "\x1b\x5b2m"; -const RESET: &str = "\x1b\x5b0m"; -const PARSER_TEMPLATE: &str = "\ - {all-args}{after-help}\ -"; -const SUBCOMMAND_TEMPLATE: &str = "\ - {usage-heading}\n {usage}\n\ - \n\ - {all-args}{after-help}\ -"; - -#[derive(Parser)] -#[command( - version, - about, - long_about = None, - infer_subcommands = true, - arg_required_else_help = true, - help_template = PARSER_TEMPLATE, - disable_colored_help = false, - after_help = "For command specific help use `help [COMMAND]`.\n\ - To get help in the middle of a command you can use `?`.\n\ - For example: `list ?` will print the help text for the `list` command. - ", - multicall = true -)] -struct Repl { - #[command(subcommand)] - command: Commands, -} - -#[allow(clippy::enum_variant_names)] -#[derive(Subcommand)] -enum Commands { - /// Connect to a serial port - #[command( - visible_aliases = ["c", "con"], - long_about = None, - help_template = SUBCOMMAND_TEMPLATE, - arg_required_else_help = true - )] - Connect { - /// The path to a serial port - /// - /// For Linux/MacOS something like `/dev/ttyUSB0`, Windows `COM1`. - port: PathBuf, - /// Baud rate for the serial connection - #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] - baud: u32, - /// Path to a file to write the session's output - #[arg(short, long)] - file: Option>, - /// Start the session in the background (headless) - #[arg(long)] - bg: bool, - #[clap(flatten)] - config_override: ConfigOverrides, - }, - /// Close a session - #[command( - visible_alias = "k", - help_template = SUBCOMMAND_TEMPLATE, - arg_required_else_help = true - )] - Kill { - /// A space-delimited list of sessions to terminate - /// - /// For example: `kill 0 2 3` or `kill 0` - session: Vec, - }, - /// List helpful information - #[command( - visible_aliases = ["ls","l"], - help_template = SUBCOMMAND_TEMPLATE, - arg_required_else_help = true - )] - List { - #[command(subcommand)] - cmd: ListCmds, - }, - /// Exit the program - #[command(visible_aliases = ["e", "q","quit"])] - Exit, - /// Print the version of sericom - #[command(visible_short_flag_alias = 'V', visible_long_flag_alias = "version")] - Version, - // TODO: Use this to print user settings like `git config --list` - // Set { - // /* Stuff to set settings */ - // } -} - -#[derive(Subcommand)] -enum ListCmds { - /// List the current sessions/connections - #[command(visible_alias = "s")] - Sessions, - /// List available serial ports - #[command(visible_alias = "p")] - Ports, - /// List valid baud rates - #[command(visible_alias = "b")] - Bauds, - /// List the current configuration - #[command(visible_alias = "conf")] - Config, -} - -#[derive(Parser, Debug)] -struct ConfigOverrides { - /// Set the forground color for the text - #[arg(short, long, requires_all = &["port"], value_parser = color_parser)] - color: Option, - /// Override the `out-dir` for the file - /// - /// Alternatively could simply use the absolute path - #[arg(short, long, requires_all = &["port", "file"], value_parser = validate_dir)] - out_dir: Option, - /// Override the `script` that's run after writing to a file - #[arg(long, requires_all = &["port", "file"], value_parser = is_script)] - script: Option, -} - -impl From for sericom_core::configs::ConfigOverride { - fn from(overrides: ConfigOverrides) -> Self { - sericom_core::configs::ConfigOverride { - color: overrides.color, - out_dir: overrides.out_dir, - script: overrides.script, - } - } -} +mod repl; +use repl::*; #[tokio::main] async fn main() -> miette::Result<()> { @@ -165,137 +26,6 @@ async fn main() -> miette::Result<()> { run_repl().await } -async fn run_repl() -> miette::Result<()> { - let conf = rustyline::Config::builder() - .history_ignore_space(true) - .completion_show_all_if_ambiguous(true) - .completion_type(rustyline::CompletionType::Circular) - .build(); - let mut rl = rustyline::DefaultEditor::with_config(conf) - .into_diagnostic() - .wrap_err("Failed to create REPL")?; - let history_path = std::env::home_dir() - .unwrap_or(PathBuf::from("./")) - .join(".sericom_history"); - - if rl.load_history(&history_path).is_err() { - println!("{DIM}No previous history{RESET}"); - } - println!("Welcome to the sericom, type 'exit' to quit."); - - let mut manager = sericom_core::session::SessionManager::new(); - loop { - let readline = rl.readline(">> "); - match readline { - Ok(line) => { - let line = line.trim(); - if line.is_empty() { - continue; - } - - rl.add_history_entry(line).ok(); - - if line.contains('?') { - handle_help(line); - continue; - } - - match Repl::try_parse_from(line.split_whitespace().collect::>()) { - Ok(cli) => match handle_cmds(cli.command, &mut manager).await { - Ok(true) => break, - Ok(false) => {} - Err(e) => { - println!("{RED}\u{1F5F4} {e}{RESET}"); - for e in e.chain().skip(1) { - println!(" {RED}\u{2937}{RESET} {e}"); - } - - if let Some(help) = e.help() { - println!("\n{CYAN}{BOLD}help:{RESET} {help}\n"); - } - } - }, - Err(e) => e.print().expect("error printing clap error"), - } - } - Err(rustyline::error::ReadlineError::Interrupted) => continue, - Err(err) => { - tracing::debug!(target: "repl", %err, "got unknown error from rustyline"); - break; - } - } - } - - rl.save_history(&history_path).ok(); - manager.graceful_shutdown().await; - Ok(()) -} - -async fn handle_cmds( - cmd: Commands, - manager: &mut sericom_core::session::SessionManager, -) -> miette::Result { - match cmd { - Commands::Connect { - port, - baud, - #[allow(unused)] - config_override, - file, - bg, - } => match (file.as_ref(), bg) { - (Some(_), bg) => manager.spawn(port, baud, file, bg).map(|_| false), - (None, bg) => manager.spawn(port, baud, None, bg).map(|_| false), - }, - Commands::Kill { session } => { - for id in session { - manager.kill(id).await; - tracing::debug!(target: "repl::kill", "session {id} closed"); - } - Ok(false) - } - Commands::List { cmd } => match cmd { - ListCmds::Ports => list_serial_ports().map(|_| false), - ListCmds::Bauds => { - println!("Valid baud rates:"); - for baud in serial2_tokio::COMMON_BAUD_RATES { - println!("{baud}"); - } - Ok(false) - } - ListCmds::Sessions => { - manager.list(&mut std::io::stdout()); - Ok(false) - } - ListCmds::Config => todo!(), - }, - Commands::Exit => Ok(true), - Commands::Version => { - println!("{}", Repl::command().render_long_version()); - Ok(false) - } - } -} - -fn handle_help(line: &str) { - let mut cmd = Repl::command(); - let tokens: Vec<&str> = line.split_whitespace().collect(); - if tokens == ["?"] || tokens.is_empty() { - cmd.print_help().expect("help won't err"); - return; - } - - if let Some((sub, rest)) = tokens.split_first() - && *rest == ["?"] - && let Some(mut sub_cmd) = cmd - .get_subcommands_mut() - .find(|s| s.get_name() == *sub) - .cloned() - { - sub_cmd.print_help().expect("help won't err"); - } -} - fn init_tracing( dbg_dir: &Path, ) -> miette::Result> { diff --git a/sericom/src/repl.rs b/sericom/src/repl.rs new file mode 100644 index 0000000..e1f9612 --- /dev/null +++ b/sericom/src/repl.rs @@ -0,0 +1,285 @@ +use clap::{CommandFactory, Parser, Subcommand}; +use miette::{Context as _, IntoDiagnostic}; +use sericom_core::{ + cli::{color_parser, list_serial_ports, valid_baud_rate}, + path_utils::{is_script, validate_dir}, +}; +use std::path::PathBuf; + +const DIM: &str = "\x1b\x5b2m"; +const RESET: &str = "\x1b\x5b0m"; +const PARSER_TEMPLATE: &str = "\ + {all-args}{after-help}\ +"; +const SUBCOMMAND_TEMPLATE: &str = "\ + {usage-heading}\n {usage}\n\ + \n\ + {all-args}{after-help}\ +"; + +#[derive(Parser)] +#[command( + version, + about, + long_about = None, + infer_subcommands = true, + arg_required_else_help = true, + help_template = PARSER_TEMPLATE, + disable_colored_help = false, + after_help = "For command specific help use `help [COMMAND]`.\n\ + To get help in the middle of a command you can use `?`.\n\ + For example: `list ?` will print the help text for the `list` command. + ", + multicall = true +)] +struct Repl { + #[command(subcommand)] + command: Commands, +} + +#[allow(clippy::enum_variant_names)] +#[derive(Subcommand)] +enum Commands { + /// Connect to a serial port + #[command( + visible_aliases = ["c", "con"], + long_about = None, + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true + )] + Connect { + /// The path to a serial port + /// + /// For Linux/MacOS something like `/dev/ttyUSB0`, Windows `COM1`. + port: PathBuf, + /// Baud rate for the serial connection + #[arg(short, long, value_parser = valid_baud_rate, default_value_t = 9600)] + baud: u32, + /// Path to a file to write the session's output + #[arg(short, long)] + file: Option>, + /// Start the session in the background (headless) + #[arg(long)] + bg: bool, + #[clap(flatten)] + config_override: ConfigOverrides, + }, + /// Close a session + #[command( + visible_alias = "k", + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true + )] + Kill { + /// A space-delimited list of sessions to terminate + /// + /// For example: `kill 0 2 3` or `kill 0` + session: Vec, + }, + /// List helpful information + #[command( + visible_aliases = ["ls","l"], + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true + )] + List { + #[command(subcommand)] + cmd: ListCmds, + }, + /// Exit the program + #[command(visible_aliases = ["e", "q","quit"])] + Exit, + /// Print the version of sericom + #[command(visible_short_flag_alias = 'V', visible_long_flag_alias = "version")] + Version, + // TODO: Use this to print user settings like `git config --list` + // Set { + // /* Stuff to set settings */ + // } +} + +#[derive(Subcommand)] +enum ListCmds { + /// List the current sessions/connections + #[command(visible_alias = "s")] + Sessions, + /// List available serial ports + #[command(visible_alias = "p")] + Ports, + /// List valid baud rates + #[command(visible_alias = "b")] + Bauds, + /// List the current configuration + #[command(visible_alias = "conf")] + Config, +} + +#[derive(Parser, Debug)] +struct ConfigOverrides { + /// Set the forground color for the text + #[arg(short, long, requires_all = &["port"], value_parser = color_parser)] + color: Option, + /// Override the `out-dir` for the file + /// + /// Alternatively could simply use the absolute path + #[arg(short, long, requires_all = &["port", "file"], value_parser = validate_dir)] + out_dir: Option, + /// Override the `script` that's run after writing to a file + #[arg(long, requires_all = &["port", "file"], value_parser = is_script)] + script: Option, +} + +impl From for sericom_core::configs::ConfigOverride { + fn from(overrides: ConfigOverrides) -> Self { + sericom_core::configs::ConfigOverride { + color: overrides.color, + out_dir: overrides.out_dir, + script: overrides.script, + } + } +} + +pub async fn run_repl() -> miette::Result<()> { + let conf = rustyline::Config::builder() + .history_ignore_space(true) + .completion_show_all_if_ambiguous(true) + .completion_type(rustyline::CompletionType::Circular) + .build(); + let mut rl = rustyline::DefaultEditor::with_config(conf) + .into_diagnostic() + .wrap_err("Failed to create REPL")?; + let history_path = std::env::home_dir() + .unwrap_or(PathBuf::from("./")) + .join(".sericom_history"); + + if rl.load_history(&history_path).is_err() { + println!("{DIM}No previous history{RESET}"); + } + println!("Welcome to the sericom, type 'exit' to quit."); + + let mut manager = sericom_core::session::SessionManager::new(); + loop { + let readline = rl.readline(">> "); + match readline { + Ok(line) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + + rl.add_history_entry(line).ok(); + + if line.contains('?') { + handle_help(line); + continue; + } + + match Repl::try_parse_from(line.split_whitespace().collect::>()) { + Ok(cli) => match handle_cmds(cli.command, &mut manager).await { + Ok(true) => break, + Ok(false) => { + manager + .check_errors(|errs| { + for (id, err) in errs { + println!("[session {id}] {err:?}"); + } + }) + .await; + } + Err(e) => println!("{e:?}"), + }, + Err(e) => e.print().expect("error printing clap error"), + } + } + Err(rustyline::error::ReadlineError::Interrupted) => continue, + Err(err) => { + tracing::debug!(target: "repl", %err, "got unknown error from rustyline"); + break; + } + } + } + + rl.save_history(&history_path).ok(); + manager.graceful_shutdown().await; + Ok(()) +} + +async fn handle_cmds( + cmd: Commands, + manager: &mut sericom_core::session::SessionManager, +) -> miette::Result { + match cmd { + Commands::Connect { + port, + baud, + #[allow(unused)] + config_override, + file, + bg, + } => { + let res = match (file.as_ref(), bg) { + (Some(_), bg) => manager.spawn(port, baud, file, bg).map(|_| false), + (None, bg) => manager.spawn(port, baud, None, bg).map(|_| false), + }; + + // Allow initialization errors to propogate + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + manager + .check_errors(|errs| { + for (id, err) in errs { + println!("[session {id}]:\n{err:?}"); + } + }) + .await; + res + } + Commands::Kill { session } => { + for id in session { + manager.kill(id).await; + tracing::debug!(target: "repl::kill", "session {id} closed"); + } + Ok(false) + } + Commands::List { cmd } => match cmd { + ListCmds::Ports => list_serial_ports() + .wrap_err("Failed to list available ports. Platform unsupported") + .map(|_| false), + ListCmds::Bauds => { + println!("Valid baud rates:"); + for baud in serial2_tokio::COMMON_BAUD_RATES { + println!("{baud}"); + } + Ok(false) + } + ListCmds::Sessions => { + manager.list(&mut std::io::stdout()); + Ok(false) + } + ListCmds::Config => todo!(), + }, + Commands::Exit => Ok(true), + Commands::Version => { + println!("{}", Repl::command().render_long_version()); + Ok(false) + } + } +} + +fn handle_help(line: &str) { + let mut cmd = Repl::command(); + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens == ["?"] || tokens.is_empty() { + cmd.print_help().expect("help won't err"); + return; + } + + if let Some((sub, rest)) = tokens.split_first() + && *rest == ["?"] + && let Some(mut sub_cmd) = cmd + .get_subcommands_mut() + .find(|s| s.get_name() == *sub) + .cloned() + { + sub_cmd.print_help().expect("help won't err"); + } +} From 3bc357c3e46be8b0c7820bb0c12d525cb7d61c11 Mon Sep 17 00:00:00 2001 From: tkatter Date: Mon, 15 Dec 2025 18:33:47 -0600 Subject: [PATCH 36/40] refactor(error,tracing): init_tracing() was broken, more error work changelog: ignore --- sericom-core/src/path_utils/macros.rs | 21 ++++++++++++++ sericom-core/src/serial_actor/mod.rs | 2 +- sericom-core/src/session/error.rs | 14 +++++++-- sericom-core/src/session/handle.rs | 2 +- sericom-core/src/session/mod.rs | 12 ++++++++ sericom/src/main.rs | 41 ++++++++++++++------------- sericom/src/repl.rs | 23 +++++++++++++-- 7 files changed, 89 insertions(+), 26 deletions(-) diff --git a/sericom-core/src/path_utils/macros.rs b/sericom-core/src/path_utils/macros.rs index 88f134c..2c34520 100644 --- a/sericom-core/src/path_utils/macros.rs +++ b/sericom-core/src/path_utils/macros.rs @@ -158,6 +158,14 @@ macro_rules! compat_port_path { )) }}; + (trace, $dir:expr) => {{ + use chrono; + + $dir.join( + format!("sericom-log-{}.txt", chrono::Utc::now().format("%m%d%H%M")) + ) + }}; + ($out_dir:expr, $port:expr) => {{ use chrono; @@ -323,3 +331,16 @@ impl ExpandPaths for std::path::PathBuf { Some(self) } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + #[test] + fn assert_expanded_home_path() { + use crate::path_utils::ExpandPaths; + + let path = PathBuf::from("~"); + assert_eq!(path.get_expanded_path(), std::env::home_dir()); + } +} diff --git a/sericom-core/src/serial_actor/mod.rs b/sericom-core/src/serial_actor/mod.rs index b4dee7e..875f0d8 100644 --- a/sericom-core/src/serial_actor/mod.rs +++ b/sericom-core/src/serial_actor/mod.rs @@ -7,7 +7,7 @@ use crate::SeriError; /// Represents messages/commands that are sent from worker tasks to the [`SerialActor`] to process. #[non_exhaustive] -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum SerialMessage { /// Instructs the [`SerialActor`] to write bytes (`Vec`) to the serial connection. Write(Vec), diff --git a/sericom-core/src/session/error.rs b/sericom-core/src/session/error.rs index 8c58f23..f7cc221 100644 --- a/sericom-core/src/session/error.rs +++ b/sericom-core/src/session/error.rs @@ -3,6 +3,8 @@ use thiserror::Error; #[derive(Debug, Clone, Diagnostic, Error)] pub enum SeriError { + #[error("Invalid session id: {0}")] + Session(super::SessionID), #[error("Task error: {0:?}")] TaskError(String), #[error("{}", ctx.as_ref().map_or("I/O Error", |c| c))] @@ -20,6 +22,10 @@ pub enum SeriError { #[help] help: Option, }, + #[error("Serial connection error")] + SendErr ( + #[from] tokio::sync::mpsc::error::SendError, + ), } #[allow(unused)] @@ -30,17 +36,21 @@ impl SeriError { Self::TaskError(_) => todo!(), Self::Io { source, help, ctx } => *ctx = Some(msg), Self::Connection { source, help } => todo!(), + Self::SendErr(_) => todo!(), + Self::Session(_) => todo!(), } - + self } - + #[must_use] pub fn add_help(mut self, msg: String) -> Self { match &mut self { Self::TaskError(_) => todo!(), Self::Io { source, help, ctx } => *help = Some(msg), Self::Connection { source, help } => *help = Some(msg), + Self::SendErr(_) => todo!(), + Self::Session(_) => todo!(), } self diff --git a/sericom-core/src/session/handle.rs b/sericom-core/src/session/handle.rs index 7323d74..e4bcafd 100644 --- a/sericom-core/src/session/handle.rs +++ b/sericom-core/src/session/handle.rs @@ -352,7 +352,7 @@ async fn parse_task( Ok(()) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use std::{ env::{current_dir, temp_dir}, diff --git a/sericom-core/src/session/mod.rs b/sericom-core/src/session/mod.rs index 72e339f..458ea80 100644 --- a/sericom-core/src/session/mod.rs +++ b/sericom-core/src/session/mod.rs @@ -7,6 +7,8 @@ mod handle; pub use error::SeriError; pub use handle::SessionHandle; +use crate::serial_actor::SerialMessage; + pub type SessionID = u8; pub type UpdatedId = (SessionID, SessionID); @@ -45,6 +47,16 @@ impl SessionManager { } } + pub async fn send(&mut self, id: SessionID, msg: &str) -> crate::Result<()> { + if let Some(session) = self.get_mut_session(id) { + let mut msg = msg.as_bytes().to_vec(); + msg.push(b'\n'); + session.tx.send(SerialMessage::Write(msg)).await.map_err(SeriError::from) + } else { + Err(SeriError::Session(id)) + } + } + /// Get a reference to the [`SessionHandle`] of [`SessionID`]. /// /// Returns `None` if the [`SessionID`] is invalid. diff --git a/sericom/src/main.rs b/sericom/src/main.rs index 3917ed5..2c81779 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -9,7 +9,8 @@ use miette::{Context as _, IntoDiagnostic}; use sericom_core::configs::{get_config, initialize_config}; -use std::path::{Path, PathBuf}; +use tracing_subscriber::filter::FilterExt; +use std::path::{Path}; mod repl; use repl::*; @@ -30,13 +31,13 @@ fn init_tracing( dbg_dir: &Path, ) -> miette::Result> { use sericom_core::compat_port_path; - use tracing::{Level, level_filters::LevelFilter}; + use tracing::{level_filters::LevelFilter}; use tracing_subscriber::EnvFilter; - use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::layer::{Layer, SubscriberExt}; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{filter, fmt}; - let path = compat_port_path!(dbg_dir); + let path = compat_port_path!(trace, dbg_dir); let file = std::fs::File::options() .write(true) .create(true) @@ -47,6 +48,21 @@ fn init_tracing( let (non_blocking, guard) = tracing_appender::non_blocking(file); + let targets_filter = filter::Targets::new() + .with_target("sericom_core", tracing::Level::TRACE) + .with_target("sericom", tracing::Level::TRACE) + .with_default(tracing::Level::ERROR); + let env_filter = EnvFilter::builder() + .with_default_directive( + // #[cfg(debug_assertions)] + // LevelFilter::TRACE.into(), + // #[cfg(not(debug_assertions))] + LevelFilter::INFO.into(), + ) + .with_env_var("SERI_LOG") + .from_env_lossy(); + + tracing_subscriber::registry() .with( fmt::layer() @@ -54,22 +70,7 @@ fn init_tracing( .with_line_number(false) .with_target(true), ) - .with( - filter::Targets::new() - .with_target("sericom", Level::TRACE) - .with_target("sericom_core", Level::TRACE) - .with_default(Level::ERROR), - ) - .with( - EnvFilter::builder() - .with_default_directive( - #[cfg(debug_assertions)] - LevelFilter::TRACE.into(), - #[cfg(not(debug_assertions))] - LevelFilter::INFO.into(), - ) - .from_env_lossy(), - ) + .with(targets_filter.and_then(env_filter)) .init(); Ok(Some(guard)) diff --git a/sericom/src/repl.rs b/sericom/src/repl.rs index e1f9612..f5f5125 100644 --- a/sericom/src/repl.rs +++ b/sericom/src/repl.rs @@ -76,6 +76,19 @@ enum Commands { /// For example: `kill 0 2 3` or `kill 0` session: Vec, }, + /// Write to a session + #[command( + help_template = SUBCOMMAND_TEMPLATE, + arg_required_else_help = true, + )] + Send { + /// A space-delimited list of sessions to send the message to + /// + /// For example: `kill 0 2 3 hello world` or `send 0 hello world` + session: sericom_core::session::SessionID, + #[arg(allow_hyphen_values=true, trailing_var_arg=true)] + message: Vec, + }, /// List helpful information #[command( visible_aliases = ["ls","l"], @@ -181,7 +194,7 @@ pub async fn run_repl() -> miette::Result<()> { manager .check_errors(|errs| { for (id, err) in errs { - println!("[session {id}] {err:?}"); + println!("[session {id}]:\n{err:?}"); } }) .await; @@ -236,10 +249,16 @@ async fn handle_cmds( Commands::Kill { session } => { for id in session { manager.kill(id).await; - tracing::debug!(target: "repl::kill", "session {id} closed"); + tracing::debug!("session {id} closed"); } Ok(false) } + Commands::Send { session, message } => { + let msg = message.join(" "); + manager.send(session, &msg).await.map_err(miette::Report::from)?; + tracing::debug!(%session, %msg, "sent message"); + Ok(false) + } Commands::List { cmd } => match cmd { ListCmds::Ports => list_serial_ports() .wrap_err("Failed to list available ports. Platform unsupported") From 68724a643a12793757f1a73347f47537bedcf39e Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Thu, 18 Dec 2025 11:45:47 -0600 Subject: [PATCH 37/40] refactor(parsing): Refactoring parsing/handling sequences --- sericom-core/src/screen/mod.rs | 4 +- sericom-core/src/screen/process/c1_ctrl.rs | 1 + .../src/screen/{ => process}/driver.rs | 60 ++++-- sericom-core/src/screen/process/mod.rs | 25 +-- sericom-core/src/screen/process/parser.rs | 21 +- sericom-core/src/screen/process/xterm.rs | 186 ++++++++++++++++++ 6 files changed, 253 insertions(+), 44 deletions(-) create mode 100644 sericom-core/src/screen/process/c1_ctrl.rs rename sericom-core/src/screen/{ => process}/driver.rs (75%) create mode 100644 sericom-core/src/screen/process/xterm.rs diff --git a/sericom-core/src/screen/mod.rs b/sericom-core/src/screen/mod.rs index fa16427..7009a39 100644 --- a/sericom-core/src/screen/mod.rs +++ b/sericom-core/src/screen/mod.rs @@ -21,7 +21,6 @@ mod buffer; mod components; -mod driver; mod position; pub mod process; mod rect; @@ -30,9 +29,8 @@ mod ui_command; pub use buffer::ScreenBuffer; pub use components::{Cell, Line, Span}; -pub use driver::ScreenDriver; pub use position::{BuffPos, Cursor, PosType, PosY, Position, Scope, TermPos, TranslatePos}; -pub use process::{ByteParser, ColorState, ParseState, ParserEvent}; +pub use process::{ByteParser, ColorState, ParseState, ParserEvent, ScreenDriver}; pub use rect::Rect; pub use ui_command::{UIAction, UICommand}; diff --git a/sericom-core/src/screen/process/c1_ctrl.rs b/sericom-core/src/screen/process/c1_ctrl.rs new file mode 100644 index 0000000..fe5ff8f --- /dev/null +++ b/sericom-core/src/screen/process/c1_ctrl.rs @@ -0,0 +1 @@ +pub fn process_c1() {} diff --git a/sericom-core/src/screen/driver.rs b/sericom-core/src/screen/process/driver.rs similarity index 75% rename from sericom-core/src/screen/driver.rs rename to sericom-core/src/screen/process/driver.rs index d60b987..e20685b 100644 --- a/sericom-core/src/screen/driver.rs +++ b/sericom-core/src/screen/process/driver.rs @@ -3,12 +3,13 @@ use std::fmt::Write; use crossterm::style::Attributes; use tracing::trace; -use crate::screen::process::SEP; +use crate::screen::process::{BS, C1, CR, ESC, FF, HT, NL, SEMI}; -use super::ScreenBuffer; -use super::components::{Cell, Line}; -use super::position::Cursor; -use super::process::{BK, BS, CR, ColorState, ESC, FF, NL, ParserEvent, TAB}; +use super::super::ScreenBuffer; +use super::super::components::{Cell, Line}; +use super::super::position::Cursor; +use super::super::process::{ColorState, ParserEvent}; +use super::process_c1; use super::{process_colors, process_cursor, process_erase}; /// The layer between incoming [`ParserEvent`]s and the [`ScreenBuffer`]. @@ -84,7 +85,7 @@ impl<'a> ScreenDriver<'a> { .push_line(Line::new_empty(self.buffer.width() as usize)); self.buffer.update_view(None); } - TAB => { + HT => { self.buffer.with_current_span(|span, _| { span.push(Cell::TAB); }); @@ -103,9 +104,9 @@ impl<'a> ScreenDriver<'a> { let s = seq.iter().fold(String::new(), |mut output, b| { if *b == ESC { let _ = write!(output, "ESC"); - } else if *b == BK { + } else if *b == b'[' { let _ = write!(output, "["); - } else if *b == SEP { + } else if *b == SEMI { let _ = write!(output, ";"); } let _ = write!(output, "{}", *b as char); @@ -128,6 +129,7 @@ impl<'a> ScreenDriver<'a> { tracing::debug!("EscSequenceType::Screen unimplemented, got: {:X?}", kind); /* process_screen(seq, kind, self.buffer) */ } + EscSequenceType::C1(ctl) => process_c1(), } } } @@ -142,21 +144,45 @@ pub enum EscSequenceType { Graphics, /// Set screen modes Screen(u8), + C1(C1), +} + +impl TryFrom for EscSequenceType { + type Error = crate::SeriError; + + fn try_from(value: u8) -> Result { + match value { + b'm' => Ok(EscSequenceType::Graphics), + b'A'..=b'G' | b'H' | b'f' | b'n' | b's' | b'u' => Ok(EscSequenceType::Cursor(value)), + b'J' | b'K' => Ok(EscSequenceType::Erase(value)), + b'h' | b'l' => Ok(EscSequenceType::Screen(value)), + v => C1::try_from(v).map(EscSequenceType::C1), + } + } } pub fn classify_escape_seq(seq: &[u8]) -> Option { // https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 // Ensures the sequence resembles: ESC[ - if seq.len() < 3 || seq[0] != ESC || seq[1] != BK { + if seq.len() < 2 || seq[0] != ESC { return None; } - let last = *seq.last().expect("Verified len != 0"); - match last { - b'm' => Some(EscSequenceType::Graphics), - b'A'..=b'G' | b'H' | b'f' | b'n' | b's' | b'u' => Some(EscSequenceType::Cursor(last)), - b'J' | b'K' => Some(EscSequenceType::Erase(last)), - b'h' | b'l' => Some(EscSequenceType::Screen(last)), - _ => None, - } + EscSequenceType::try_from(seq[1]).ok() + + // match seq[1] { + // b'[' => { + // let last = *seq.last().expect("Verified len != 0"); + // match last { + // b'm' => Some(EscSequenceType::Graphics), + // b'A'..=b'G' | b'H' | b'f' | b'n' | b's' | b'u' => { + // Some(EscSequenceType::Cursor(last)) + // } + // b'J' | b'K' => Some(EscSequenceType::Erase(last)), + // b'h' | b'l' => Some(EscSequenceType::Screen(last)), + // _ => None, + // } + // } + // ctrl => Some(EscSequenceType::C1(C1::try_from(ctrl).ok()?)), + // } } diff --git a/sericom-core/src/screen/process/mod.rs b/sericom-core/src/screen/process/mod.rs index 6d26626..e04b962 100644 --- a/sericom-core/src/screen/process/mod.rs +++ b/sericom-core/src/screen/process/mod.rs @@ -1,28 +1,15 @@ +mod c1_ctrl; mod colors; mod cursor; +mod driver; mod parser; mod screen; +mod xterm; +pub use c1_ctrl::process_c1; pub use colors::{ColorState, process_colors}; pub use cursor::process_cursor; +pub use driver::ScreenDriver; pub use parser::{ByteParser, ParseState, ParserEvent}; pub use screen::process_erase; - -/// Bracket '[' -pub(crate) const BK: u8 = b'['; -/// Backspace -pub(crate) const BS: u8 = 0x08; -/// Carrige return '\r' -pub(crate) const CR: u8 = 0x0D; -/// Escape 'ESC' -pub(crate) const ESC: u8 = 0x1B; -/// Newline '\n' -pub(crate) const NL: u8 = 0x0A; -/// Escape sequence separator ';' -pub(crate) const SEP: u8 = b';'; -/// Tab '\t' -pub(crate) const TAB: u8 = 0x09; -/// Form feed -pub(crate) const FF: u8 = 0x0C; -/// Reset graphics mode escape sequence -pub(crate) const RESET: &[u8] = &[ESC, BK, b'0', b'm']; +pub use xterm::*; diff --git a/sericom-core/src/screen/process/parser.rs b/sericom-core/src/screen/process/parser.rs index e489709..5ae286d 100644 --- a/sericom-core/src/screen/process/parser.rs +++ b/sericom-core/src/screen/process/parser.rs @@ -9,8 +9,8 @@ pub enum ParserEvent { impl std::fmt::Display for ParserEvent { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - use crate::screen::driver::EscSequenceType; - use crate::screen::driver::classify_escape_seq; + use super::driver::EscSequenceType; + use super::driver::classify_escape_seq; match self { Self::Text(items) => { let s = str::from_utf8(items).expect("parsed text is utf-8"); @@ -51,6 +51,9 @@ impl std::fmt::Display for ParserEvent { } format!("Screen( ESC[{s} )") } + EscSequenceType::C1(val) => { + format!("{val:#?}") + } }; f.write_str(&s) @@ -72,8 +75,8 @@ pub struct ByteParser { buffer: Vec, } -impl ByteParser { - pub(crate) const fn new() -> Self { +impl Default for ByteParser { + fn default() -> Self { Self { state: ParseState::Normal, buffer: vec![], @@ -82,13 +85,21 @@ impl ByteParser { } impl ByteParser { - pub(crate) fn feed(&mut self, data: &[u8]) -> Vec { + #[must_use] + pub const fn new() -> Self { + Self { + state: ParseState::Normal, + buffer: vec![], + } + } + pub fn feed(&mut self, data: &[u8]) -> Vec { let mut events: Vec = Vec::new(); for &b in data { if !b.is_ascii() { continue; } + match self.state { ParseState::Normal => match b { ESC => { diff --git a/sericom-core/src/screen/process/xterm.rs b/sericom-core/src/screen/process/xterm.rs new file mode 100644 index 0000000..7c0ab8c --- /dev/null +++ b/sericom-core/src/screen/process/xterm.rs @@ -0,0 +1,186 @@ +//! Resources: +//! - [xterm's docs] The source +//! - [nwm article] Nice writeup, good format for quick review +//! - [xterm.js] Nice implementation/action descriptions +//! - [ascii table] Useful hex/binary/decimal ascii resource +//! +//! [ascii table]: https://www.ascii-code.com/ +//! [xterm.js]: https://xtermjs.org/docs/api/vtfeatures/ +//! [nwm article]: https://nicholas-morris.com/articles/ansi-codes +//! [xterm's docs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html +#![allow(clippy::upper_case_acronyms)] + +pub const NUL: u8 = 0x00; // Null character +pub const SOH: u8 = 0x01; // Start of Heading +pub const STX: u8 = 0x02; // Start of Text +pub const ETX: u8 = 0x03; // End of Text +pub const EOT: u8 = 0x04; // End of Transmission +pub const ENQ: u8 = 0x05; // Enquiry +pub const ACK: u8 = 0x06; // Acknowledge +pub const BEL: u8 = 0x07; // Bell, Alert +pub const BS: u8 = 0x08; // Backspace +pub const HT: u8 = 0x09; // Horizontal Tab +pub const NL: u8 = 0x0A; // Newline \n (Line Feed) +pub const VT: u8 = 0x0B; // Vertical Tabulation +pub const FF: u8 = 0x0C; // Form Feed +pub const CR: u8 = 0x0D; // Carriage Return +pub const SO: u8 = 0x0E; // Shift Out +pub const SI: u8 = 0x0F; // Shift In +pub const DLE: u8 = 0x10; // Data Link Escape +pub const DC1: u8 = 0x11; // Device Control One (XON) +pub const DC2: u8 = 0x12; // Device Control Two +pub const DC3: u8 = 0x13; // Device Control Three (XOFF) +pub const DC4: u8 = 0x14; // Device Control Four +pub const NAK: u8 = 0x15; // Negative Acknowledge +pub const SYN: u8 = 0x16; // Synchronous Idle +pub const ETB: u8 = 0x17; // End of Transmission Block +pub const CAN: u8 = 0x18; // Cancel +pub const EM: u8 = 0x19; // End of medium +pub const SUB: u8 = 0x1A; // Substitute +pub const ESC: u8 = 0x1B; // Escape +pub const FS: u8 = 0x1C; // File Separator +pub const GS: u8 = 0x1D; // Group Separator +pub const RS: u8 = 0x1E; // Record Separator +pub const US: u8 = 0x1F; // Unit Separator +pub const DEL: u8 = 0x7F; // Delete +pub const SEMI: u8 = b';'; // Escape sequence separator ';' + +/// Reset graphics mode escape sequence +pub const RESET: &[u8] = &[ESC, b'[', b'0', b'm']; + +/// Impl TryFrom for zig-like enum +macro_rules! def_xterm_tf { + ( + #[repr($as:ty)] + $(#[$attr:meta])* + pub enum $i:ident { + $( + $(#[$doc:meta])? + $variant:ident = $val:literal + ),+ + } + error = $err:literal + ) => { + #[repr($as)] + $(#[$attr])* + pub enum $i { + $( + $(#[$doc])? + $variant = $val, + )* + } + + impl TryFrom<$as> for $i { + type Error = $crate::SeriError; + + fn try_from(val: $as) -> Result { + match val { + $($val => Ok($i::$variant),)+ + _ => Err($crate::SeriError::Parsing(format!($err, val))) + } + } + } + }; +} + +def_xterm_tf! { + #[repr(u8)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + #[doc = "C1 Control Codes (ESC __)"] + pub enum C1 { + /// Index (0x84) + IND = b'D', + /// Next Line (0x85) + NEL = b'E', + /// Tab Set (0x88) + HTS = b'H', + /// Reverse Index (0x8d) + RI = b'M', + /// Device Control String (0x90) + DCS = b'P', + /// Start of Guarded Area (0x96) + SPA = b'V', + /// End of Guarded Area (0x97) + EPA = b'W', + /// Start of String (0x98) + SOS = b'X', + /// Return Terminal ID (0x9a) + DECID = b'Z', + /// Control Sequence Intoducer (0x9b) + CSI = b'[', + /// String Terminator (0x9c) + ST = b'\\', + /// Operating System Command (0x9d) + OSC = b']', + /// Privacy Message (0x9e) + PM = b'^', + /// Application Program Command (0x9f) + APC = b'_', + /// Back Index (VT420+) + DECBI = b'6', + /// Save Cursor (VT100) + DECSC = b'7', + /// Restore Cursor (VT100) + DECRC = b'8', + /// Forward Index (VT420+) + DECFI = b'9', + /// Full Reset (VT100) + RIS = b'c' + } + error = "Could not parse {:00x?} as C1" +} + +def_xterm_tf! { + #[repr(u8)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum Cursor { + CharRel = b'a', + Up = b'A', + Rep = b'b', + Down = b'B', + Forward = b'C', + LineAbs = b'd', + Backward = b'D', + LineRel = b'e', + NextLine = b'E', + PositionHVP = b'f', + PrecedingLine = b'F', + CharAbsCHA = b'G', + PositionCUP = b'H', + ForwardTab = b'I', + ScrollUp = b'S', + ScrollDown = b'T', + BackTab = b'Z', + CharAbsHPA = b'`' + } + error = "Could not parse {:00x?} as Cursor" +} + +def_xterm_tf! { + #[repr(u8)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum EraseKind { + Screen = b'J', + Line = b'K', + Chars = b'X' + } + error = "Could not parse {:00x?} as EraseKind" +} + +def_xterm_tf! { + #[repr(u8)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum EraseMode { + Below = b'0', + Above = b'1', + All = b'2', + Scrollback = b'3' + } + error = "Could not parse {:00x?} as EraseMode" +} + +/* Where to stick these +* CSI Ps L Insert Ps Line(s) (default = 1) (IL). +* CSI Ps M Delete Ps Line(s) (default = 1) (DL). +* CSI Ps P Delete Ps Character(s) (default = 1) (DCH). +*/ From 8e00d0e10278a8590a0eaf70c4f0d3c2829395cb Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Fri, 19 Dec 2025 11:40:46 -0600 Subject: [PATCH 38/40] refactor(parsing): More parsing/control sequence handling changelog: ignore --- Cargo.toml | 9 + sericom-core/src/configs/mod.rs | 4 +- sericom-core/src/path_utils/macros.rs | 7 +- sericom-core/src/screen/position.rs | 37 ++- sericom-core/src/screen/process/c1_ctrl.rs | 1 - sericom-core/src/screen/process/colors.rs | 12 +- sericom-core/src/screen/process/cursor.rs | 20 +- sericom-core/src/screen/process/driver.rs | 187 ++++++------- sericom-core/src/screen/process/mod.rs | 20 +- sericom-core/src/screen/process/parser.rs | 162 +++++------- sericom-core/src/screen/process/xterm.rs | 289 ++++++++++++++------- sericom-core/src/serial_actor/mod.rs | 20 +- sericom-core/src/session/error.rs | 12 +- sericom-core/src/session/handle.rs | 88 ++++++- sericom-core/src/session/mod.rs | 48 +++- sericom/src/main.rs | 17 +- sericom/src/repl.rs | 7 +- 17 files changed, 584 insertions(+), 356 deletions(-) delete mode 100644 sericom-core/src/screen/process/c1_ctrl.rs diff --git a/Cargo.toml b/Cargo.toml index 9277ea6..d9535e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,15 @@ tracing = "0.1.43" [patch.crates-io] sericom-core = { path = "sericom-core" } +# Keeps usefull information for perf report +[profile.perf] +inherits = "release" +debug = true # keep DWARF info for perf/flamegraph +lto = "fat" # don't merge crates; keeps crate boundaries visible +codegen-units = 1 # better optimizer consistency +opt-level = 3 # full optimization +strip = "none" # ensure debug info isn't stripped + # The profile that 'dist' will build with [profile.dist] inherits = "release" diff --git a/sericom-core/src/configs/mod.rs b/sericom-core/src/configs/mod.rs index 759d43c..f438284 100644 --- a/sericom-core/src/configs/mod.rs +++ b/sericom-core/src/configs/mod.rs @@ -55,10 +55,10 @@ impl Config { } } -#[cfg(test)] +// #[cfg(test)] static INIT: std::sync::Once = std::sync::Once::new(); -#[cfg(test)] +// #[cfg(test)] pub fn init_for_tests() { INIT.call_once(|| { CONFIG diff --git a/sericom-core/src/path_utils/macros.rs b/sericom-core/src/path_utils/macros.rs index 2c34520..b1d5bca 100644 --- a/sericom-core/src/path_utils/macros.rs +++ b/sericom-core/src/path_utils/macros.rs @@ -161,9 +161,10 @@ macro_rules! compat_port_path { (trace, $dir:expr) => {{ use chrono; - $dir.join( - format!("sericom-log-{}.txt", chrono::Utc::now().format("%m%d%H%M")) - ) + $dir.join(format!( + "sericom-log-{}.txt", + chrono::Utc::now().format("%m%d%H%M") + )) }}; ($out_dir:expr, $port:expr) => {{ diff --git a/sericom-core/src/screen/position.rs b/sericom-core/src/screen/position.rs index 3573a3b..45ce1c5 100644 --- a/sericom-core/src/screen/position.rs +++ b/sericom-core/src/screen/position.rs @@ -3,6 +3,8 @@ use std::{fmt::Display, marker::PhantomData}; use super::Rect; use super::ScreenBuffer; +const TAB_WIDTH: u16 = 8; + #[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] pub struct TermPos; #[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] @@ -50,11 +52,28 @@ impl Position { } impl Position { + pub const fn tabn(&mut self, n: u16) { + let mut acc = 0; + while acc < n { + self.tab(); + acc += 1; + } + } + pub const fn rtabn(&mut self, n: u16) { + let mut acc = 0; + while acc < n { + self.rtab(); + acc += 1; + } + } pub const fn tab(&mut self) { - let tab_width = 8; - let tab = tab_width - (self.x % tab_width); + let tab = TAB_WIDTH - (self.x % TAB_WIDTH); self.x += tab; } + pub const fn rtab(&mut self) { + let tab = TAB_WIDTH - (self.x % TAB_WIDTH); + self.x.saturating_sub(tab); + } pub const fn set_y(&mut self, y: PosY) { self.y = y; } @@ -183,9 +202,23 @@ pub trait Cursor: HasBounds { /// Useful when the movement is not relative to the cursor's current /// position, but a direct/absolute position. fn set_cursor_row(&mut self, row: u16); + fn tab(&mut self, n: u16); + fn rtab(&mut self, n: u16); } impl Cursor for ScreenBuffer { + fn tab(&mut self, n: u16) { + let bounds = self.bounds(); + self.cursor.tabn(n); + if self.cursor.x > bounds.right() { + self.cursor.x = bounds.right(); + } + } + + fn rtab(&mut self, n: u16) { + self.cursor.tabn(n); + } + fn set_cursor_pos

(&mut self, position: P) where P: Into>, diff --git a/sericom-core/src/screen/process/c1_ctrl.rs b/sericom-core/src/screen/process/c1_ctrl.rs deleted file mode 100644 index fe5ff8f..0000000 --- a/sericom-core/src/screen/process/c1_ctrl.rs +++ /dev/null @@ -1 +0,0 @@ -pub fn process_c1() {} diff --git a/sericom-core/src/screen/process/colors.rs b/sericom-core/src/screen/process/colors.rs index d74ea08..de47b72 100644 --- a/sericom-core/src/screen/process/colors.rs +++ b/sericom-core/src/screen/process/colors.rs @@ -5,7 +5,7 @@ use crossterm::{ use miette::IntoDiagnostic; use std::io::Write; -use super::SEP; +use super::SEMI; use crate::configs::get_config; #[derive(Debug)] @@ -61,11 +61,9 @@ impl ColorState { } } -pub fn process_colors(seq: &[u8], color_state: &mut ColorState, attrs: &mut Attributes) { - // Get the part between 'ESC[' and 'm' - let body = &seq[2..seq.len() - 1]; +pub fn process_colors(params: &[u8], color_state: &mut ColorState, attrs: &mut Attributes) { let mut body_idx = 0; - let mut parts_iter = body.split(|&p| p == SEP).peekable(); + let mut parts_iter = params.split(|&p| p == SEMI).peekable(); while let Some(part) = parts_iter.next() { match part { @@ -78,7 +76,7 @@ pub fn process_colors(seq: &[u8], color_state: &mut ColorState, attrs: &mut Attr // Get slice from body (+ 1 for the separators ';') let part_str_len = part.len() + 1 + ident.len() + 1 + color.len(); - let slice = &body[body_idx..body_idx + part_str_len]; + let slice = ¶ms[body_idx..body_idx + part_str_len]; if let Ok(s) = std::str::from_utf8(slice) { color_state.set_colors(s); @@ -94,7 +92,7 @@ pub fn process_colors(seq: &[u8], color_state: &mut ColorState, attrs: &mut Attr // get slice from body (+ 1 for the separators ';') let part_str_len = part.len() + 1 + ident.len() + 1 + r.len() + 1 + g.len() + 1 + b.len(); - let slice = &body[body_idx..body_idx + part_str_len]; + let slice = ¶ms[body_idx..body_idx + part_str_len]; if let Ok(s) = std::str::from_utf8(slice) { color_state.set_colors(s); diff --git a/sericom-core/src/screen/process/cursor.rs b/sericom-core/src/screen/process/cursor.rs index 64ed923..801d994 100644 --- a/sericom-core/src/screen/process/cursor.rs +++ b/sericom-core/src/screen/process/cursor.rs @@ -1,28 +1,18 @@ use crate::screen::{ ScreenBuffer, position::{Cursor, Position}, - process::SEP, + process::{SEMI, digits_to_int}, }; -fn ascii_digits_to_integer(body: &[u8]) -> Option { - if body.is_empty() || !body.iter().all(u8::is_ascii_digit) { - return None; - } - Some( - body.iter() - .fold(0u16, |acc, b| acc * 10 + u16::from(b & 0x0F)), - ) -} - pub fn process_cursor(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { let body = &seq[2..seq.len() - 1]; // ESC[{row};{col}H // move cursor to line # col # if (kind == b'H' || kind == b'f') && !body.is_empty() { - let mut parts = body.split(|&b| b == SEP); - let row = parts.next().and_then(ascii_digits_to_integer); - let col = parts.next().and_then(ascii_digits_to_integer); + let mut parts = body.split(|&b| b == SEMI); + let row = parts.next().and_then(digits_to_int); + let col = parts.next().and_then(digits_to_int); if let (Some(c), Some(r)) = (col, row) { sb.set_cursor_pos((c, r)); } @@ -31,7 +21,7 @@ pub fn process_cursor(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { // need to send to device via ESC[row;colR todo!(); } else { - let nums = ascii_digits_to_integer(body); + let nums = digits_to_int(body); match kind { b'A' => { // move cursor up # lines diff --git a/sericom-core/src/screen/process/driver.rs b/sericom-core/src/screen/process/driver.rs index e20685b..e68e0b4 100644 --- a/sericom-core/src/screen/process/driver.rs +++ b/sericom-core/src/screen/process/driver.rs @@ -1,16 +1,8 @@ -use std::fmt::Write; - use crossterm::style::Attributes; use tracing::trace; -use crate::screen::process::{BS, C1, CR, ESC, FF, HT, NL, SEMI}; - -use super::super::ScreenBuffer; -use super::super::components::{Cell, Line}; -use super::super::position::Cursor; -use super::super::process::{ColorState, ParserEvent}; -use super::process_c1; -use super::{process_colors, process_cursor, process_erase}; +use super::{C0, C1, CSI, ColorState, CsiKind, ParserEvent, SEMI, digits_to_int, process_colors}; +use crate::screen::{Cell, Cursor as _, Line, ScreenBuffer}; /// The layer between incoming [`ParserEvent`]s and the [`ScreenBuffer`]. pub struct ScreenDriver<'a> { @@ -29,7 +21,7 @@ impl<'a> ScreenDriver<'a> { } /// Returns the number of newlines written - pub fn process_events(&mut self, events: Vec) -> u32 { + pub fn process_events<'b>(&mut self, events: Vec>) -> u32 { let span = tracing::trace_span!("process"); let _enter = span.enter(); @@ -38,13 +30,14 @@ impl<'a> ScreenDriver<'a> { trace!(%event); match event { ParserEvent::Text(bytes) => self.write_text(&bytes), - ParserEvent::Control(ctrl) => { - if ctrl == b'\n' { + ParserEvent::CSI(csi) => self.handle_csi(csi), + ParserEvent::C1(c1) => self.handle_c1(c1), + ParserEvent::C0(c0) => { + if c0 == C0::NL { newlines += 1; } - self.handle_control(ctrl); + self.handle_c0(c0); } - ParserEvent::EscapeSequence(seq) => self.handle_escape(&seq), } } newlines @@ -63,10 +56,10 @@ impl<'a> ScreenDriver<'a> { self.buffer.move_cursor_right(bytes.len() as u16); } - fn handle_control(&mut self, ctrl: u8) { - match ctrl { - BS => self.buffer.move_cursor_left(1), - NL => { + fn handle_c0(&mut self, c0: C0) { + match c0 { + C0::BS => self.buffer.move_cursor_left(1), + C0::NL => { self.buffer.with_current_line(|line, _| { // pushing to the end of line unconditionally because // NL is always the end of a line, and if received a CR @@ -85,104 +78,94 @@ impl<'a> ScreenDriver<'a> { .push_line(Line::new_empty(self.buffer.width() as usize)); self.buffer.update_view(None); } - HT => { + C0::HT => { self.buffer.with_current_span(|span, _| { span.push(Cell::TAB); }); self.buffer.cursor.tab(); } - CR => self.buffer.set_cursor_col(0), - // Not sure that FF needs to be handled - #[allow(clippy::match_same_arms)] - FF => {} + C0::CR => self.buffer.set_cursor_col(0), _ => {} } } - fn handle_escape(&mut self, seq: &[u8]) { - let Some(seq_type) = classify_escape_seq(seq) else { - let s = seq.iter().fold(String::new(), |mut output, b| { - if *b == ESC { - let _ = write!(output, "ESC"); - } else if *b == b'[' { - let _ = write!(output, "["); - } else if *b == SEMI { - let _ = write!(output, ";"); + fn handle_csi(&mut self, csi: CSI) { + match csi.kind { + CsiKind::CursorUp => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_up(int); + } + CsiKind::CursorDown => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_down(int); + } + CsiKind::CursorForward => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_right(int); + } + CsiKind::CursorBackward => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_left(int); + } + CsiKind::NextLine => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_down(int); + self.buffer.set_cursor_col(0); + } + CsiKind::PrecedingLine => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.move_cursor_up(int); + self.buffer.set_cursor_col(0); + } + CsiKind::CharAbsCHA => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.set_cursor_col(int); + } + CsiKind::PositionCUP => { + if csi.params.is_empty() { + self.buffer.set_cursor_pos((1u16, 1u16)); + return; + } + if let Some(idx) = csi.params.iter().position(|b| *b == SEMI) { + let (row, col) = ( + digits_to_int(&csi.params[0..idx]).unwrap_or(1), + digits_to_int(&csi.params[idx + 1..]).unwrap_or(1), + ); + self.buffer.set_cursor_pos((col, row)); + } else { + self.buffer.set_cursor_pos((1u16, 1u16)); } - let _ = write!(output, "{}", *b as char); - output - }); - tracing::debug!(target: "parser::escape", sequence=%s, "Failed to classify escape sequence"); - return; - }; - match seq_type { - EscSequenceType::Cursor(kind) => { - process_cursor(seq, kind, self.buffer); } - EscSequenceType::Erase(kind) => process_erase(seq, kind, self.buffer), - EscSequenceType::Graphics => { - process_colors(seq, &mut self.color_state, &mut self.attrs); + CsiKind::ForwardTab => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.tab(int); + } + CsiKind::EraseScreen => todo!(), + CsiKind::EraseLine => todo!(), + CsiKind::InsertLine => todo!(), + CsiKind::DeleteLine => todo!(), + CsiKind::DeleteChars => todo!(), + CsiKind::ScrollUp => todo!(), + CsiKind::ScrollDown => todo!(), + CsiKind::EraseChars => todo!(), + CsiKind::BackTab => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.rtab(int); + } + CsiKind::CharRel => todo!(), + CsiKind::Rep => todo!(), + CsiKind::LineAbs => todo!(), + CsiKind::LineRel => todo!(), + CsiKind::PositionHVP => todo!(), + CsiKind::CharAbsHPA => todo!(), + CsiKind::Sgr => { + process_colors(csi.params, &mut self.color_state, &mut self.attrs); self.buffer .handle_span_colors(&self.color_state, self.attrs); } - EscSequenceType::Screen(kind) => { - tracing::debug!("EscSequenceType::Screen unimplemented, got: {:X?}", kind); - /* process_screen(seq, kind, self.buffer) */ - } - EscSequenceType::C1(ctl) => process_c1(), + CsiKind::ScrollDown1991 => todo!(), } } -} - -/// The category of escape sequence determined by the last char of the sequence. -pub enum EscSequenceType { - /// Control cursor movement - Cursor(u8), - /// Sequences that clear the screen - Erase(u8), - /// Color changes/modes - Graphics, - /// Set screen modes - Screen(u8), - C1(C1), -} - -impl TryFrom for EscSequenceType { - type Error = crate::SeriError; - - fn try_from(value: u8) -> Result { - match value { - b'm' => Ok(EscSequenceType::Graphics), - b'A'..=b'G' | b'H' | b'f' | b'n' | b's' | b'u' => Ok(EscSequenceType::Cursor(value)), - b'J' | b'K' => Ok(EscSequenceType::Erase(value)), - b'h' | b'l' => Ok(EscSequenceType::Screen(value)), - v => C1::try_from(v).map(EscSequenceType::C1), - } - } -} - -pub fn classify_escape_seq(seq: &[u8]) -> Option { - // https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797 - // Ensures the sequence resembles: ESC[ - if seq.len() < 2 || seq[0] != ESC { - return None; - } - - EscSequenceType::try_from(seq[1]).ok() - // match seq[1] { - // b'[' => { - // let last = *seq.last().expect("Verified len != 0"); - // match last { - // b'm' => Some(EscSequenceType::Graphics), - // b'A'..=b'G' | b'H' | b'f' | b'n' | b's' | b'u' => { - // Some(EscSequenceType::Cursor(last)) - // } - // b'J' | b'K' => Some(EscSequenceType::Erase(last)), - // b'h' | b'l' => Some(EscSequenceType::Screen(last)), - // _ => None, - // } - // } - // ctrl => Some(EscSequenceType::C1(C1::try_from(ctrl).ok()?)), - // } + fn handle_c1(&mut self, c1: C1) {} } diff --git a/sericom-core/src/screen/process/mod.rs b/sericom-core/src/screen/process/mod.rs index e04b962..757277d 100644 --- a/sericom-core/src/screen/process/mod.rs +++ b/sericom-core/src/screen/process/mod.rs @@ -1,4 +1,3 @@ -mod c1_ctrl; mod colors; mod cursor; mod driver; @@ -6,10 +5,27 @@ mod parser; mod screen; mod xterm; -pub use c1_ctrl::process_c1; pub use colors::{ColorState, process_colors}; pub use cursor::process_cursor; pub use driver::ScreenDriver; pub use parser::{ByteParser, ParseState, ParserEvent}; pub use screen::process_erase; pub use xterm::*; + +pub const fn digits_to_int(body: &[u8]) -> Option { + if body.is_empty() { + return None; + } + + let mut acc: u16 = 0; + let mut i = 0; + while i < body.len() { + let b = body[i]; + if (b < b'0') || (b > b'9') { + return None; + } + acc = acc * 10 + (b & 0x0F) as u16; + i += 1; + } + Some(acc) +} diff --git a/sericom-core/src/screen/process/parser.rs b/sericom-core/src/screen/process/parser.rs index 5ae286d..6933062 100644 --- a/sericom-core/src/screen/process/parser.rs +++ b/sericom-core/src/screen/process/parser.rs @@ -1,87 +1,42 @@ -use super::ESC; +use crate::screen::process::{C0, C1, CSI, CsiKind}; #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ParserEvent { - Text(Vec), - Control(u8), - EscapeSequence(Vec), +pub enum ParserEvent<'a> { + Text(&'a [u8]), + CSI(CSI<'a>), + C0(C0), + C1(C1), } -impl std::fmt::Display for ParserEvent { +impl<'a> std::fmt::Display for ParserEvent<'a> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - use super::driver::EscSequenceType; - use super::driver::classify_escape_seq; match self { Self::Text(items) => { - let s = str::from_utf8(items).expect("parsed text is utf-8"); - f.write_fmt(format_args!("Text( {s} )")) - } - Self::Control(b) => f.write_fmt(format_args!("Control( {b:#02X} )")), - Self::EscapeSequence(items) => { - let Some(etype) = classify_escape_seq(items) else { - return f.write_str("INVALID ESC SEQ"); - }; - - let s = match etype { - EscSequenceType::Cursor(_) => { - let mut s = String::new(); - for b in &items[2..items.len()] { - s.push(char::from(*b)); - } - format!("Cursor( ESC[{s} )") - } - EscSequenceType::Erase(_) => { - let mut s = String::new(); - for b in &items[2..items.len()] { - s.push(char::from(*b)); - } - format!("Erase( ESC[{s} )") - } - EscSequenceType::Graphics => { - let mut s = String::new(); - for b in &items[2..items.len()] { - s.push(char::from(*b)); - } - format!("Graphics( ESC[{s} )") - } - EscSequenceType::Screen(_) => { - let mut s = String::new(); - for b in &items[2..items.len()] { - s.push(char::from(*b)); - } - format!("Screen( ESC[{s} )") - } - EscSequenceType::C1(val) => { - format!("{val:#?}") - } - }; - - f.write_str(&s) + // SAFETY: Only bytes within the ascii printable text range + // are processed as Self::Text + write!(f, "Text( {} )", unsafe { str::from_utf8_unchecked(items) }) } + Self::C0(c0) => write!(f, "C0( {c0} )"), + Self::C1(c1) => write!(f, "C1( {c1} )"), + Self::CSI(csi) => write!(f, "CSI( {csi} )"), } } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub enum ParseState { + #[default] Normal, Esc, Csi, + Osc, + Dcs, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ByteParser { state: ParseState, - buffer: Vec, -} - -impl Default for ByteParser { - fn default() -> Self { - Self { - state: ParseState::Normal, - buffer: vec![], - } - } + start_idx: Option, } impl ByteParser { @@ -89,66 +44,79 @@ impl ByteParser { pub const fn new() -> Self { Self { state: ParseState::Normal, - buffer: vec![], + start_idx: None, } } - pub fn feed(&mut self, data: &[u8]) -> Vec { - let mut events: Vec = Vec::new(); - for &b in data { + pub fn feed<'a>(&mut self, data: &'a [u8]) -> Vec> { + let mut events: Vec = Vec::new(); + for (idx, &b) in data.iter().enumerate() { if !b.is_ascii() { continue; } match self.state { ParseState::Normal => match b { - ESC => { - if !self.buffer.is_empty() { - events.push(ParserEvent::Text(std::mem::take(&mut self.buffer))); - } - self.buffer.push(b); - self.state = ParseState::Esc; - } 0x00..=0x1F | 0x7F => { - if !self.buffer.is_empty() { - events.push(ParserEvent::Text(std::mem::take(&mut self.buffer))); + if let Ok(ctrl) = C0::try_from(b) { + if let Some(start) = self.start_idx.take() { + events.push(ParserEvent::Text(&data[start..idx])); + } + + if ctrl == C0::ESC { + self.state = ParseState::Esc; + } else { + events.push(ParserEvent::C0(ctrl)); + } } - events.push(ParserEvent::Control(b)); } // Regular text - _ => { - self.buffer.push(b); + _ if self.start_idx.is_none() => { + self.start_idx = Some(idx); } + _ => {} }, ParseState::Esc => { - self.buffer.push(b); - if b == b'[' { - self.state = ParseState::Csi; - } else { - // Incomplete escape sequence but push as escape sequence anyway if - // it is nonesense - the consumer will not do anything with it later. - events.push(ParserEvent::EscapeSequence(std::mem::take( - &mut self.buffer, - ))); + let Ok(ctrl) = C1::try_from(b) else { self.state = ParseState::Normal; + continue; + }; + match ctrl { + C1::CSI => self.state = ParseState::Csi, + C1::DCS => self.state = ParseState::Dcs, + C1::OSC => self.state = ParseState::Osc, + _ => { + events.push(ParserEvent::C1(ctrl)); + self.state = ParseState::Normal; + } } } ParseState::Csi => { - self.buffer.push(b); - // Csi is terminated by a regular letter [a-z][A-Z] - if b.is_ascii_alphabetic() { - events.push(ParserEvent::EscapeSequence(std::mem::take( - &mut self.buffer, - ))); + if let Ok(kind) = CsiKind::try_from(b) { + match self.start_idx.take() { + Some(start) => { + events.push(ParserEvent::CSI(CSI { + kind, + params: &data[start..idx], + })); + } + None => events.push(ParserEvent::CSI(CSI { kind, params: &[] })), + } self.state = ParseState::Normal; + } else { + self.start_idx = Some(idx); } } + ParseState::Dcs => todo!(), + ParseState::Osc => todo!(), } } // Flush buffer if it is regular text - if self.state == ParseState::Normal && !self.buffer.is_empty() { - events.push(ParserEvent::Text(std::mem::take(&mut self.buffer))); + if self.state == ParseState::Normal + && let Some(start) = self.start_idx.take() + { + events.push(ParserEvent::Text(&data[start..])); } events } diff --git a/sericom-core/src/screen/process/xterm.rs b/sericom-core/src/screen/process/xterm.rs index 7c0ab8c..0606cde 100644 --- a/sericom-core/src/screen/process/xterm.rs +++ b/sericom-core/src/screen/process/xterm.rs @@ -10,43 +10,10 @@ //! [xterm's docs]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html #![allow(clippy::upper_case_acronyms)] -pub const NUL: u8 = 0x00; // Null character -pub const SOH: u8 = 0x01; // Start of Heading -pub const STX: u8 = 0x02; // Start of Text -pub const ETX: u8 = 0x03; // End of Text -pub const EOT: u8 = 0x04; // End of Transmission -pub const ENQ: u8 = 0x05; // Enquiry -pub const ACK: u8 = 0x06; // Acknowledge -pub const BEL: u8 = 0x07; // Bell, Alert -pub const BS: u8 = 0x08; // Backspace -pub const HT: u8 = 0x09; // Horizontal Tab -pub const NL: u8 = 0x0A; // Newline \n (Line Feed) -pub const VT: u8 = 0x0B; // Vertical Tabulation -pub const FF: u8 = 0x0C; // Form Feed -pub const CR: u8 = 0x0D; // Carriage Return -pub const SO: u8 = 0x0E; // Shift Out -pub const SI: u8 = 0x0F; // Shift In -pub const DLE: u8 = 0x10; // Data Link Escape -pub const DC1: u8 = 0x11; // Device Control One (XON) -pub const DC2: u8 = 0x12; // Device Control Two -pub const DC3: u8 = 0x13; // Device Control Three (XOFF) -pub const DC4: u8 = 0x14; // Device Control Four -pub const NAK: u8 = 0x15; // Negative Acknowledge -pub const SYN: u8 = 0x16; // Synchronous Idle -pub const ETB: u8 = 0x17; // End of Transmission Block -pub const CAN: u8 = 0x18; // Cancel -pub const EM: u8 = 0x19; // End of medium -pub const SUB: u8 = 0x1A; // Substitute -pub const ESC: u8 = 0x1B; // Escape -pub const FS: u8 = 0x1C; // File Separator -pub const GS: u8 = 0x1D; // Group Separator -pub const RS: u8 = 0x1E; // Record Separator -pub const US: u8 = 0x1F; // Unit Separator -pub const DEL: u8 = 0x7F; // Delete -pub const SEMI: u8 = b';'; // Escape sequence separator ';' - /// Reset graphics mode escape sequence -pub const RESET: &[u8] = &[ESC, b'[', b'0', b'm']; +pub const RESET: &[u8] = &[0x1B, b'[', b'0', b'm']; +/// Escape sequence separator ';' +pub const SEMI: u8 = b';'; /// Impl TryFrom for zig-like enum macro_rules! def_xterm_tf { @@ -55,9 +22,9 @@ macro_rules! def_xterm_tf { $(#[$attr:meta])* pub enum $i:ident { $( - $(#[$doc:meta])? + $(#[$doc:meta])* $variant:ident = $val:literal - ),+ + ),* $(,)? } error = $err:literal ) => { @@ -65,9 +32,9 @@ macro_rules! def_xterm_tf { $(#[$attr])* pub enum $i { $( - $(#[$doc])? - $variant = $val, - )* + $(#[$doc])* + $variant = $val + ),* } impl TryFrom<$as> for $i { @@ -86,101 +53,231 @@ macro_rules! def_xterm_tf { def_xterm_tf! { #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum C0 { + /// Null character + NUL = 0x00, + /// Start of Heading + SOH = 0x01, + /// Start of Text + STX = 0x02, + /// End of Text + ETX = 0x03, + /// End of Transmission + EOT = 0x04, + /// Enquiry + ENQ = 0x05, + /// Acknowledge + ACK = 0x06, + /// Bell, Alert + BEL = 0x07, + /// Backspace + BS = 0x08, + /// Horizontal Tab + HT = 0x09, + /// Newline \n (Line Feed) + NL = 0x0A, + /// Vertical Tabulation + VT = 0x0B, + /// Form Feed + FF = 0x0C, + /// Carriage Return + CR = 0x0D, + /// Shift Out + SO = 0x0E, + /// Shift In + SI = 0x0F, + /// Data Link Escape + DLE = 0x10, + /// Device Control One (XON) + DC1 = 0x11, + /// Device Control Two + DC2 = 0x12, + /// Device Control Three (XOFF) + DC3 = 0x13, + /// Device Control Four + DC4 = 0x14, + /// Negative Acknowledge + NAK = 0x15, + /// Synchronous Idle + SYN = 0x16, + /// End of Transmission Block + ETB = 0x17, + /// Cancel + CAN = 0x18, + /// End of medium + EM = 0x19, + /// Substitute + SUB = 0x1A, + /// Escape + ESC = 0x1B, + /// File Separator + FS = 0x1C, + /// Group Separator + GS = 0x1D, + /// Record Separator + RS = 0x1E, + /// Unit Separator + US = 0x1F, + /// Delete + DEL = 0x7F + } + error = "Could not parse {:#X?} as C0" +} + +impl std::fmt::Display for C0 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:#X?}", *self as u8) + } +} + +def_xterm_tf! { + #[repr(u8)] + #[non_exhaustive] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[doc = "C1 Control Codes (ESC __)"] pub enum C1 { - /// Index (0x84) + /// Index 'D' (0x84) IND = b'D', - /// Next Line (0x85) + /// Next Line 'E' (0x85) NEL = b'E', - /// Tab Set (0x88) + /// Tab Set 'H' (0x88) HTS = b'H', - /// Reverse Index (0x8d) + /// Reverse Index 'M' (0x8d) RI = b'M', - /// Device Control String (0x90) + /// Device Control String 'P' (0x90) DCS = b'P', - /// Start of Guarded Area (0x96) + /// Start of Guarded Area 'V' (0x96) SPA = b'V', - /// End of Guarded Area (0x97) + /// End of Guarded Area 'W' (0x97) EPA = b'W', - /// Start of String (0x98) + /// Start of String 'X' (0x98) SOS = b'X', - /// Return Terminal ID (0x9a) + /// Return Terminal ID 'Z' (0x9a) DECID = b'Z', - /// Control Sequence Intoducer (0x9b) + /// Control Sequence Intoducer '[' (0x9b) CSI = b'[', - /// String Terminator (0x9c) + /// String Terminator '\' (0x9c) ST = b'\\', - /// Operating System Command (0x9d) + /// Operating System Command ']' (0x9d) OSC = b']', - /// Privacy Message (0x9e) + /// Privacy Message '^' (0x9e) PM = b'^', - /// Application Program Command (0x9f) + /// Application Program Command '_' (0x9f) APC = b'_', - /// Back Index (VT420+) + /// Back Index '6' (VT420+) DECBI = b'6', - /// Save Cursor (VT100) + /// Save Cursor '7' (VT100) DECSC = b'7', - /// Restore Cursor (VT100) + /// Restore Cursor '8' (VT100) DECRC = b'8', - /// Forward Index (VT420+) + /// Forward Index '9' (VT420+) DECFI = b'9', - /// Full Reset (VT100) + /// Full Reset 'c' (VT100) RIS = b'c' } - error = "Could not parse {:00x?} as C1" + error = "Could not parse {:#X?} as C1" +} + +impl std::fmt::Display for C1 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", *self as u8 as char) + } } def_xterm_tf! { #[repr(u8)] + #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - pub enum Cursor { - CharRel = b'a', - Up = b'A', - Rep = b'b', - Down = b'B', - Forward = b'C', - LineAbs = b'd', - Backward = b'D', - LineRel = b'e', + pub enum CsiKind { + /// Cursor Up _Ps_ Times 'A' (default = 1) (CUU) + CursorUp = b'A', + /// Cursor Down _Ps_ Times 'B' (default = 1) (CUD) + CursorDown = b'B', + /// Cursor Forward _Ps_ Times 'C' (default = 1) (CUF) + CursorForward = b'C', + /// Cursor Backward _Ps_ Times 'D' (default = 1) (CUB) + CursorBackward = b'D', + /// Cursor Next Line _Ps_ Times 'E' (default = 1) (CNL) NextLine = b'E', - PositionHVP = b'f', + /// Cursor Preceding Line _Ps_ Times 'F' (default = 1) (CPL) PrecedingLine = b'F', + /// Cursor Character Absolute [column] 'G' (default = [row,1]) (CHA) CharAbsCHA = b'G', + /// Cursor Position [row;column] 'H' (default = [1,1]) (CUP) PositionCUP = b'H', + /// Cursor Forward Tabulation _Ps_ tab stops 'I' (default = 1) (CHT) ForwardTab = b'I', + /// Erase in Display 'J' + /// + /// VT100: CSI _Ps_ J (ED) + /// VT220: CSI ? _Ps_ J (DECSED) + EraseScreen = b'J', + /// Erase in Line 'K' + /// + /// VT100: CSI _Ps_ K (EL) + /// VT220: CSI ? _Ps_ K (DECSEL) + EraseLine = b'K', + /// Insert _Ps_ Line(s) 'L' (default = 1) (IL) + InsertLine = b'L', + /// Delete _Ps_ Line(s) 'M' (default = 1) (DL) + DeleteLine = b'M', + /// Delete _Ps_ Character(s) 'P' (default = 1) (DCH) + DeleteChars = b'P', + /// Scroll up _Ps_ lines 'S' (default = 1) (SU) ScrollUp = b'S', + /// Scroll down _Ps_ lines 'T' (default = 1) (SD) ScrollDown = b'T', + /// Erase _Ps_ Character(s) 'X' (default = 1) (ECH) + EraseChars = b'X', + /// Cursor Backward Tabulation _Ps_ tab stops 'Z' (default = 1) (CBT) BackTab = b'Z', - CharAbsHPA = b'`' + /// Character Position Relative [columns] 'a' (default = [row,col+1]) (HPR) + CharRel = b'a', + /// Repeat the preceding graphic character _Ps_ times 'b' (REP) + Rep = b'b', + /// Line Position Absolute [row] 'd' (default = [1,column]) (VPA) + LineAbs = b'd', + /// Line Position Relative [rows] 'e' (default = [row+1,column]) (VPR) + LineRel = b'e', + /// Horizontal and Vertical Position [row;column] 'f' (default = [1,1]) (HVP) + PositionHVP = b'f', + /// Character Attributes 'm' (SGR) + Sgr = b'm', + /// Character Position Absolute [column] '`' (default = [row,1]) (HPA) + CharAbsHPA = b'`', + /// Scroll down _Ps_ lines '^' (default = 1) (SD) + /// + /// From [xterm]: + /// > This was a publication error in the original ECMA-48 5th edition + /// > (1991) corrected in 2003. + /// + /// [xterm]: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_ + ScrollDown1991 = b'^' } - error = "Could not parse {:00x?} as Cursor" + error = "Could not parse {:#X?} as CsiKind" } -def_xterm_tf! { - #[repr(u8)] - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - pub enum EraseKind { - Screen = b'J', - Line = b'K', - Chars = b'X' +impl std::fmt::Display for CsiKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", *self as u8 as char) } - error = "Could not parse {:00x?} as EraseKind" } -def_xterm_tf! { - #[repr(u8)] - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - pub enum EraseMode { - Below = b'0', - Above = b'1', - All = b'2', - Scrollback = b'3' - } - error = "Could not parse {:00x?} as EraseMode" +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct CSI<'a> { + pub kind: CsiKind, + pub params: &'a [u8], } -/* Where to stick these -* CSI Ps L Insert Ps Line(s) (default = 1) (IL). -* CSI Ps M Delete Ps Line(s) (default = 1) (DL). -* CSI Ps P Delete Ps Character(s) (default = 1) (DCH). -*/ +impl<'a> std::fmt::Display for CSI<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "ESC [ {} {}", + // SAFETY: It is verified that self.params is ascii when constructed + // in ByteParser via core::num::is_ascii() + unsafe { std::str::from_utf8_unchecked(self.params) }, + self.kind + ) + } +} diff --git a/sericom-core/src/serial_actor/mod.rs b/sericom-core/src/serial_actor/mod.rs index 875f0d8..893f015 100644 --- a/sericom-core/src/serial_actor/mod.rs +++ b/sericom-core/src/serial_actor/mod.rs @@ -3,6 +3,8 @@ pub mod tasks; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + use crate::SeriError; /// Represents messages/commands that are sent from worker tasks to the [`SerialActor`] to process. @@ -44,18 +46,24 @@ pub enum SerialEvent { /// It broadcasts [`SerialEvent`]s to worker tasks via a [`tokio::sync::broadcast`] /// channel, and receives [`SerialMessage`]s from worker tasks via a [`tokio::sync::mpsc`] /// channel. -pub struct SerialActor { - connection: serial2_tokio::SerialPort, +pub struct SerialActor { + connection: S, + // connection: serial2_tokio::SerialPort, command_rx: tokio::sync::mpsc::Receiver, tasks_broadcast: tokio::sync::broadcast::Sender, } -impl SerialActor { +impl SerialActor +where + S: AsyncRead + AsyncWrite + AsyncWriteExt + Unpin + Send + 'static, +{ /// Constructs a [`SerialActor`] Takes a serial port connection, /// receiver to a command channel, and a sender to a broadcast channel. #[must_use] pub const fn new( - connection: serial2_tokio::SerialPort, + // pub const fn new( + // connection: serial2_tokio::SerialPort, + connection: S, command_rx: tokio::sync::mpsc::Receiver, tasks_broadcast: tokio::sync::broadcast::Sender, ) -> Self { @@ -100,7 +108,7 @@ impl SerialActor { } Some(SerialMessage::SendBreak) => { debug!("sending break signal"); - self.send_break().await; + // self.send_break().await; } None => { trace!("command_rx.recv() was None"); @@ -130,7 +138,9 @@ impl SerialActor { } } } +} +impl SerialActor { async fn send_break(&self) { use tokio::time::{Duration, sleep}; let _ = self.connection.set_break(true); diff --git a/sericom-core/src/session/error.rs b/sericom-core/src/session/error.rs index f7cc221..b56a8ee 100644 --- a/sericom-core/src/session/error.rs +++ b/sericom-core/src/session/error.rs @@ -3,6 +3,8 @@ use thiserror::Error; #[derive(Debug, Clone, Diagnostic, Error)] pub enum SeriError { + #[error("Parsing error: {0:?}")] + Parsing(String), #[error("Invalid session id: {0}")] Session(super::SessionID), #[error("Task error: {0:?}")] @@ -23,9 +25,7 @@ pub enum SeriError { help: Option, }, #[error("Serial connection error")] - SendErr ( - #[from] tokio::sync::mpsc::error::SendError, - ), + SendErr(#[from] tokio::sync::mpsc::error::SendError), } #[allow(unused)] @@ -38,11 +38,12 @@ impl SeriError { Self::Connection { source, help } => todo!(), Self::SendErr(_) => todo!(), Self::Session(_) => todo!(), + Self::Parsing(_) => todo!(), } - + self } - + #[must_use] pub fn add_help(mut self, msg: String) -> Self { match &mut self { @@ -51,6 +52,7 @@ impl SeriError { Self::Connection { source, help } => *help = Some(msg), Self::SendErr(_) => todo!(), Self::Session(_) => todo!(), + Self::Parsing(_) => todo!(), } self diff --git a/sericom-core/src/session/handle.rs b/sericom-core/src/session/handle.rs index e4bcafd..43dab8e 100644 --- a/sericom-core/src/session/handle.rs +++ b/sericom-core/src/session/handle.rs @@ -66,6 +66,82 @@ where res } +impl SessionHandle { + pub fn spawn_with_stream( + meta: &super::SessionMeta, + with_file: Option>, + headless: bool, + mgr_tx: oneshot::Sender, + stream: S, + ) -> miette::Result + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + AsyncWriteExt + Unpin + Send + 'static, + { + let (tx, rx) = tokio::sync::mpsc::channel::(100); + let (events_tx, _) = tokio::sync::broadcast::channel::(128); + + let (term_w, term_h) = if headless { + #[cfg(not(test))] + { + (120, 24) + } + #[cfg(test)] + { + (120, 10) + } + } else { + crossterm::terminal::size().unwrap_or((80, 24)) + }; + + let sb = ScreenBuffer::new(Rect::new((0u16, 0u16).into(), term_w, term_h)); + let buffer = Arc::new(tokio::sync::RwLock::new(sb)); + let actor = SerialActor::new(stream, rx, events_tx.clone()); + + let span = { + let info = tracing::info_span!("session"); + let dbg = tracing::debug_span!("session", port = %meta.port.display()); + if dbg.is_disabled() { info } else { dbg } + }; + let _enter = span.enter(); + debug!(port=%meta.port.display(), baud=%meta.baud, "opened connection"); + + let mut handle = Self { + buffer, + tx, + events_tx, + tasks: JoinSet::new(), + }; + handle.spawn_monitor_task(mgr_tx); + handle.spawn_actor_task(actor); + handle.spawn_parse_task(); + + if with_file.is_some() { + let config = get_config()?; + let default_out_dir = PathBuf::from(&config.defaults.out_dir); + let file_path = if let Some(Some(path)) = with_file { + // If given an absolute path - override the `default_out_dir` + if path.is_absolute() { + let parent = path.parent().unwrap_or(&default_out_dir); + create_recursive!(parent); + path + } else { + let joined_path = default_out_dir.join(&path); + let parent_path = joined_path.parent().expect("Does not have root"); + create_recursive!(parent_path); + joined_path + } + } else { + let default_out_dir = PathBuf::from(&config.defaults.out_dir); + drop(config); + compat_port_path!(default_out_dir, &meta.port) + }; + handle.spawn_file_task(file_path); + } + + Ok(handle) + } +} + impl SessionHandle { /// Spawn a new session and optionally stream to a `file` or run in headless mode. pub fn spawn( @@ -83,7 +159,7 @@ impl SessionHandle { let (term_w, term_h) = if headless { #[cfg(not(test))] { - (80, 24) + (120, 24) } #[cfg(test)] { @@ -185,7 +261,10 @@ impl SessionHandle { } /// Spawns a task that runs the [`SerialActor`] for the session. - fn spawn_actor_task(&mut self, actor: SerialActor) { + fn spawn_actor_task(&mut self, actor: SerialActor) + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + AsyncWriteExt + Unpin + Send + 'static, + { self.tasks.spawn(instrument_task( actor.run(), Some(Span::current()), @@ -372,6 +451,7 @@ mod tests { crate::configs::init_for_tests(); let bytes = include_bytes!("./mod.rs"); + assert!(!bytes.is_empty()); let tmp_dir = temp_dir(); let tmp_path = tmp_dir.join("sericom_core_test_write_a_file.txt"); let og_path = current_dir().unwrap().join("src/session/mod.rs"); @@ -388,9 +468,9 @@ mod tests { ) .unwrap(); - sleep(Duration::from_millis(500)).await; + sleep(Duration::from_millis(1)).await; master.write_all(bytes).unwrap(); - sleep(Duration::from_millis(500)).await; + sleep(Duration::from_millis(1)).await; manager.kill(id).await; diff --git a/sericom-core/src/session/mod.rs b/sericom-core/src/session/mod.rs index 458ea80..b46af6d 100644 --- a/sericom-core/src/session/mod.rs +++ b/sericom-core/src/session/mod.rs @@ -14,13 +14,13 @@ pub type UpdatedId = (SessionID, SessionID); #[derive(Debug, Default)] pub struct SessionManager { - active: Option, - id_update_tx: tokio::sync::watch::Sender>, handles: Vec, metas: Vec, monitors: Vec>, errors: Arc>>, shutting_down: Arc, + id_update_tx: tokio::sync::watch::Sender>, + active: Option, } /// Includes the baud rate and name of the port/connection. @@ -51,7 +51,11 @@ impl SessionManager { if let Some(session) = self.get_mut_session(id) { let mut msg = msg.as_bytes().to_vec(); msg.push(b'\n'); - session.tx.send(SerialMessage::Write(msg)).await.map_err(SeriError::from) + session + .tx + .send(SerialMessage::Write(msg)) + .await + .map_err(SeriError::from) } else { Err(SeriError::Session(id)) } @@ -83,7 +87,6 @@ impl SessionManager { /// # Errors /// Errors if there are already a maximum number of sessions ([`u8::MAX`]) or /// if the [`SessionHandle::spawn`] errors. - #[allow(clippy::result_unit_err)] #[allow(clippy::cast_possible_truncation)] pub fn spawn( &mut self, @@ -112,6 +115,43 @@ impl SessionManager { Ok(id as SessionID) } + #[allow(clippy::cast_possible_truncation)] + pub fn spawn_with_stream( + &mut self, + port: std::path::PathBuf, + baud: u32, + f_path: Option>, + headless: bool, + stream: S, + ) -> miette::Result + where + S: tokio::io::AsyncRead + + tokio::io::AsyncWrite + + tokio::io::AsyncWriteExt + + Unpin + + Send + + 'static, + { + let id = self.metas.len(); + + if id >= u8::MAX as usize { + warn!(%id, "max sessions reached"); + return Err(miette::miette!("Max sessions reached"))?; + } + + let (err_tx, err_rx) = oneshot::channel::(); + let meta = SessionMeta { baud, port }; + let handle = SessionHandle::spawn_with_stream(&meta, f_path, headless, err_tx, stream)?; + info!(%id, port=%meta.port.display(), %baud, "created session"); + + self.handles.push(handle); + self.metas.push(meta); + self.start_monitor(id as SessionID, err_rx); + + // Cast is fine, verified that id is < u8::MAX + Ok(id as SessionID) + } + /// Kill/shutdown the session for [`SessionID`] #[allow(clippy::cast_possible_truncation)] pub async fn kill(&mut self, id: SessionID) { diff --git a/sericom/src/main.rs b/sericom/src/main.rs index 2c81779..f3bd9c1 100644 --- a/sericom/src/main.rs +++ b/sericom/src/main.rs @@ -9,8 +9,8 @@ use miette::{Context as _, IntoDiagnostic}; use sericom_core::configs::{get_config, initialize_config}; +use std::path::Path; use tracing_subscriber::filter::FilterExt; -use std::path::{Path}; mod repl; use repl::*; @@ -31,7 +31,7 @@ fn init_tracing( dbg_dir: &Path, ) -> miette::Result> { use sericom_core::compat_port_path; - use tracing::{level_filters::LevelFilter}; + use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; use tracing_subscriber::layer::{Layer, SubscriberExt}; use tracing_subscriber::util::SubscriberInitExt; @@ -54,14 +54,13 @@ fn init_tracing( .with_default(tracing::Level::ERROR); let env_filter = EnvFilter::builder() .with_default_directive( - // #[cfg(debug_assertions)] - // LevelFilter::TRACE.into(), - // #[cfg(not(debug_assertions))] + // #[cfg(debug_assertions)] + // LevelFilter::TRACE.into(), + // #[cfg(not(debug_assertions))] LevelFilter::INFO.into(), - ) - .with_env_var("SERI_LOG") - .from_env_lossy(); - + ) + .with_env_var("SERI_LOG") + .from_env_lossy(); tracing_subscriber::registry() .with( diff --git a/sericom/src/repl.rs b/sericom/src/repl.rs index f5f5125..9a0725b 100644 --- a/sericom/src/repl.rs +++ b/sericom/src/repl.rs @@ -86,7 +86,7 @@ enum Commands { /// /// For example: `kill 0 2 3 hello world` or `send 0 hello world` session: sericom_core::session::SessionID, - #[arg(allow_hyphen_values=true, trailing_var_arg=true)] + #[arg(allow_hyphen_values = true, trailing_var_arg = true)] message: Vec, }, /// List helpful information @@ -255,7 +255,10 @@ async fn handle_cmds( } Commands::Send { session, message } => { let msg = message.join(" "); - manager.send(session, &msg).await.map_err(miette::Report::from)?; + manager + .send(session, &msg) + .await + .map_err(miette::Report::from)?; tracing::debug!(%session, %msg, "sent message"); Ok(false) } From 971dd1c6aab0dfb99054ab81701402ccbe1101be Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Sat, 20 Dec 2025 02:19:12 -0600 Subject: [PATCH 39/40] refactor(parsing,rendering): Finished replacing ScreenDriver handling Finished the implementation of the new version of Parsing/Driving. It is relatively the same, main difference is the use of `#[repr(u8)]` enums for matching on incoming bytes/ansii codes. Intent was to make readability easier and ended up making a few things easier/nicer too. Have yet to really do much testing, I know that the ORIGIN and cursor positions all over the codebase are 0-based, and they should really be 1-based so that will need to be fixed. Briefly made a test for the new ByteParser and it seems to be working fine. Really just need to start writing tests now to try and find any broken/unexpected behaviors. --- sericom-core/src/screen/buffer.rs | 202 ++++++++++++++++++++- sericom-core/src/screen/components/line.rs | 28 ++- sericom-core/src/screen/mod.rs | 2 - sericom-core/src/screen/position.rs | 28 ++- sericom-core/src/screen/process/cursor.rs | 76 -------- sericom-core/src/screen/process/driver.rs | 104 ++++++++--- sericom-core/src/screen/process/mod.rs | 3 - sericom-core/src/screen/process/parser.rs | 59 +++++- sericom-core/src/screen/process/screen.rs | 90 --------- sericom-core/src/screen/process/xterm.rs | 64 ++++++- sericom-core/src/screen/rect.rs | 4 +- sericom-core/src/screen/render.rs | 2 +- sericom-core/src/screen/tests/mod.rs | 2 +- sericom-core/src/screen/ui_command.rs | 4 +- 14 files changed, 437 insertions(+), 231 deletions(-) delete mode 100644 sericom-core/src/screen/process/cursor.rs diff --git a/sericom-core/src/screen/buffer.rs b/sericom-core/src/screen/buffer.rs index 73b86f2..3a95372 100644 --- a/sericom-core/src/screen/buffer.rs +++ b/sericom-core/src/screen/buffer.rs @@ -2,7 +2,8 @@ use crossterm::style::Attributes; use std::collections::VecDeque; use tracing::trace; -use crate::screen::{BuffPos, UICommand}; +use crate::screen::process::{EDKind, ELKind}; +use crate::screen::{BuffPos, Cell, UICommand}; use super::position::{Position, TermPos, TranslatePos}; use super::{Line, Rect}; @@ -85,7 +86,7 @@ impl ScreenBuffer { } pub(crate) fn with_current_span(&mut self, f: F) { - let buff_pos = self.to_buff(self.cursor); + let buff_pos = self.to_buff(&self.cursor); let (span, offset) = { let line = self.curr_line_mut(); @@ -100,19 +101,19 @@ impl ScreenBuffer { &mut self, f: F, ) { - let buff_pos = self.to_buff(self.cursor); + let buff_pos = self.to_buff(&self.cursor); let line = self.curr_line_mut(); f(line, &buff_pos); } pub(crate) fn curr_line(&self) -> Option<&Line> { - let pos_in_lines = self.to_buff(self.cursor); + let pos_in_lines = self.to_buff(&self.cursor); self.lines.get(pos_in_lines.y as usize) } pub(crate) fn curr_line_mut(&mut self) -> &mut Line { - let pos_in_lines = self.to_buff(self.cursor); + let pos_in_lines = self.to_buff(&self.cursor); if self.lines.get(pos_in_lines.y as usize).is_some() { self.lines .get_mut(pos_in_lines.y as usize) @@ -158,4 +159,195 @@ impl ScreenBuffer { todo!(); } + + pub(crate) fn insert_lines(&mut self, num: u16) { + let buff_pos = self.to_buff(&self.cursor); + for _ in 0..num { + self.lines.insert( + buff_pos.y as usize, + Line::new_empty(usize::from(self.width())), + ); + if self.lines.len() >= self.buff_rect().bottom() as usize { + self.lines.pop_back(); + } + } + self.cursor.x = 0; + } + + pub(crate) fn delete_lines(&mut self, num: u16) { + let buff_pos = self.to_buff(&self.cursor); + for _ in 0..num { + self.lines.remove(buff_pos.y as usize); + self.lines + .push_back(Line::new_empty(usize::from(self.width()))); + } + self.cursor.x = 0; + } + + #[allow(clippy::cast_possible_truncation)] + pub(crate) fn erase_in_display(&mut self, kind: EDKind) { + match kind { + EDKind::Below => { + let buff_range = std::ops::Range { + start: (self.to_buff(&self.cursor).y + 1) as usize, + end: (self.buff_rect().bottom() + 1) as usize, + }; + + self.clear_line_from_cursor(); + self.clear_lines(buff_range); + } + EDKind::Above => { + let buff_range = std::ops::Range { + start: self.view_start as usize, + end: self.to_buff(&self.cursor).y as usize, + }; + + self.clear_lines(buff_range); + self.clear_line_to_cursor(); + } + _ => { + self.lines + .push_back(Line::new_empty(usize::from(self.width()))); + // Casting to u32 from usize is fine because self.lines should never + // exceed MAX_SCROLLBACK which is < usize::MAX + self.view_start = (self.lines.len() as u32).saturating_sub(1); + self.cursor = Position::::ORIGIN; + } + } + } + + pub(crate) fn erase_in_line(&mut self, kind: ELKind) { + match kind { + ELKind::Right => { + self.clear_line_from_cursor(); + } + ELKind::Left => self.clear_line_to_cursor(), + ELKind::All => { + self.with_current_line(|line, _| { + line.iter_mut().flatten().for_each(|cell| { + cell.character = b' '; + }); + }); + } + } + } + + /// TODO: TEST ME + pub(crate) fn erase_chars(&mut self, num: u16) { + self.with_current_line(|line, cursor| { + line.iter_mut() + .flatten() + .skip(usize::from(cursor.x - 1)) + .take(usize::from(num)) + .for_each(|c| c.character = b' '); + }); + } + + /// TODO: TEST ME + pub(crate) fn delete_chars(&mut self, num: u16) { + self.with_current_line(|line, cursor| { + let mut ttl_del = 0; + + if line.0.len() == 1 { + if let Some(span) = line.get_mut_span(0) { + ttl_del += span + .cells + .drain(usize::from(cursor.x)..=usize::from(num)) + .len(); + span.cells.resize(span.len() + ttl_del, Cell::EMPTY); + } + } else { + let (start_span, start_off) = line.span_at_col(usize::from(cursor.x)); + let (end_span, end_off) = line.span_at_col(usize::from(cursor.x + num)); + + if start_span == end_span { + if let Some(span) = line.get_mut_span(start_span) { + ttl_del += span + .cells + .drain(usize::from(cursor.x)..=usize::from(num)) + .len(); + span.cells.shrink_to(span.len()); + } + + if let Some(span) = line.get_mut_span(end_span) { + span.cells.resize(span.len() + ttl_del, Cell::EMPTY); + } + } else if end_span - start_span == 1 { + if let Some(span) = line.get_mut_span(start_span) { + ttl_del += span.cells.drain(start_off..).len(); + span.cells.shrink_to(start_off); + } + + if let Some(span) = line.get_mut_span(end_span) { + ttl_del += span.cells.drain(..=end_off).len(); + span.cells.resize(span.len() + ttl_del, Cell::EMPTY); + } + } else { + // remove any spans between start and end span + if let Some(span) = line.get_mut_span(start_span) { + ttl_del += span.cells.drain(start_off..).len(); + span.cells.shrink_to(start_off); + } + + for span_between in start_span + 1..end_span { + ttl_del += line.0.remove(span_between).cells.len(); + } + + if let Some(span) = line.get_mut_span(end_span) { + ttl_del += span.cells.drain(..=end_off).len(); + span.cells.resize(span.len() + ttl_del, Cell::EMPTY); + } + } + } + }); + } + + fn clear_line_to_cursor(&mut self) { + self.with_current_line(|line, cursor| { + for (idx, cell) in line.iter_mut().flatten().enumerate() { + if idx < usize::from(cursor.x) { + cell.character = b' '; + } + } + }); + } + + fn clear_line_from_cursor(&mut self) { + self.with_current_line(|line, cursor| { + line.iter_mut() + .flatten() + .skip(usize::from(cursor.x)) + .for_each(|cell| { + cell.character = b' '; + }); + }); + } + + fn clear_lines(&mut self, range: std::ops::Range) { + for line in self.lines.range_mut(range) { + line.iter_mut().flatten().for_each(|cell| { + cell.character = b' '; + }); + } + } + + pub(crate) fn scroll_down(&mut self, num: u16) { + for _ in 0..num { + self.lines.insert( + self.view_start as usize, + Line::new_empty(usize::from(self.width())), + ); + if self.lines.len() >= self.buff_rect().bottom() as usize { + self.lines.pop_back(); + } + } + } + + pub(crate) fn scroll_up(&mut self, num: u16) { + for _ in 0..num { + self.view_start += 1; + self.lines + .push_back(Line::new_empty(usize::from(self.width()))); + } + } } diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index 8bcb3f6..18de871 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -84,13 +84,27 @@ impl Line { /// The index of the last [`Cell`] in `self` where [`Cell`] != ' ' (whitespace). #[must_use] pub fn last_filled_idx(&self) -> usize { - (self.num_cells() - 1) - - self - .iter() - .flatten() - .rev() - .position(|c| c.character != b' ') - .unwrap_or(0) + if self.all_whitespace() { + return 0; + } + + let len = self.num_cells(); + let p = self + .iter() + .flatten() + .rev() + .position(|c| c.character != b' ') + .unwrap_or(0); + if len - p == len { len - 1 } else { len - p } + } + + fn all_whitespace(&self) -> bool { + for c in self.iter().flatten() { + if **c != b' ' { + return false; + } + } + true } /// Whether [`Line`] contains zero _[`Span`]s_. diff --git a/sericom-core/src/screen/mod.rs b/sericom-core/src/screen/mod.rs index 7009a39..0feb345 100644 --- a/sericom-core/src/screen/mod.rs +++ b/sericom-core/src/screen/mod.rs @@ -36,5 +36,3 @@ pub use ui_command::{UIAction, UICommand}; #[cfg(test)] pub(crate) mod tests; - -pub(in crate::screen) use process::{process_colors, process_cursor, process_erase}; diff --git a/sericom-core/src/screen/position.rs b/sericom-core/src/screen/position.rs index 45ce1c5..379db4c 100644 --- a/sericom-core/src/screen/position.rs +++ b/sericom-core/src/screen/position.rs @@ -242,6 +242,7 @@ impl Cursor for ScreenBuffer { fn move_cursor_right(&mut self, cells: u16) { let bounds = self.bounds(); let mut new_x = self.cursor.x.saturating_add(cells); + if new_x > bounds.right() { new_x = bounds.right(); } @@ -252,14 +253,17 @@ impl Cursor for ScreenBuffer { self.cursor.y = self.cursor.y.saturating_sub(lines); } + // TODO: Figure out line pushes as cursor moves down fn move_cursor_down(&mut self, lines: u16) { let bounds = self.bounds(); - let new_y = self.cursor.y.saturating_add(lines); + let mut new_y = self.cursor.y.saturating_add(lines); - // TODO: FIGURE OUT LINE PUSHING - if new_y <= bounds.bottom() { - self.cursor.y = new_y; + // If the cursor would pass the bottom scroll margin, it will stop there + // [xterm.js](https://xtermjs.org/docs/api/vtfeatures/) + if new_y > bounds.bottom() { + new_y = bounds.bottom(); } + self.cursor.y = new_y; } fn set_cursor_col(&mut self, col: u16) { @@ -271,19 +275,23 @@ impl Cursor for ScreenBuffer { } } - #[allow(unused)] fn set_cursor_row(&mut self, row: u16) { - unimplemented!() + let bounds = self.bounds(); + if row > bounds.bottom() { + self.cursor.y = bounds.bottom(); + } else { + self.cursor.y = row; + } } } pub trait TranslatePos { - fn to_term(&self, pos: Position) -> Position; - fn to_buff(&self, pos: Position) -> Position; + fn to_term(&self, pos: &Position) -> Position; + fn to_buff(&self, pos: &Position) -> Position; } impl TranslatePos for ScreenBuffer { - fn to_term(&self, pos: Position) -> Position { + fn to_term(&self, pos: &Position) -> Position { let buff_win = self.buff_rect(); let visible_y = pos.y.clamp(buff_win.top(), buff_win.bottom()); @@ -299,7 +307,7 @@ impl TranslatePos for ScreenBuffer { Position::::from((term_x, term_y)) } - fn to_buff(&self, pos: Position) -> Position { + fn to_buff(&self, pos: &Position) -> Position { let buff_y: u32 = self.view_start + u32::from(pos.y); let buff_x = pos.x.clamp(0, self.width()); diff --git a/sericom-core/src/screen/process/cursor.rs b/sericom-core/src/screen/process/cursor.rs deleted file mode 100644 index 801d994..0000000 --- a/sericom-core/src/screen/process/cursor.rs +++ /dev/null @@ -1,76 +0,0 @@ -use crate::screen::{ - ScreenBuffer, - position::{Cursor, Position}, - process::{SEMI, digits_to_int}, -}; - -pub fn process_cursor(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { - let body = &seq[2..seq.len() - 1]; - - // ESC[{row};{col}H - // move cursor to line # col # - if (kind == b'H' || kind == b'f') && !body.is_empty() { - let mut parts = body.split(|&b| b == SEMI); - let row = parts.next().and_then(digits_to_int); - let col = parts.next().and_then(digits_to_int); - if let (Some(c), Some(r)) = (col, row) { - sb.set_cursor_pos((c, r)); - } - } else if kind == b'n' && body == [b'6'] { - // request cursor pos - // need to send to device via ESC[row;colR - todo!(); - } else { - let nums = digits_to_int(body); - match kind { - b'A' => { - // move cursor up # lines - if let Some(n) = nums { - sb.move_cursor_up(n); - } - } - b'B' => { - // move cursor down # lines - if let Some(n) = nums { - sb.move_cursor_down(n); - } - } - b'C' => { - // move cursor right # cols - if let Some(n) = nums { - sb.move_cursor_right(n); - } - } - b'D' => { - // move cursor left # cols - if let Some(n) = nums { - sb.move_cursor_left(n); - } - } - b'E' => { - // move to beginning of next line, - if let Some(n) = nums { - sb.set_cursor_col(0); - sb.move_cursor_down(n); - } - } - b'F' => { - // move to beginning of prev line, - if let Some(n) = nums { - sb.set_cursor_col(0); - sb.move_cursor_up(n); - } - } - b'G' => { - // move cursor to column # - if let Some(n) = nums { - sb.set_cursor_col(n); - } - } - b's' => todo!(), // sb.save_cursor_pos(stdout), // can use crossterm - b'u' => todo!(), // sb.restore_cursor_pos(stdout), // can use crossterm - b'H' => sb.set_cursor_pos(Position::ORIGIN), - _ => {} - } - } -} diff --git a/sericom-core/src/screen/process/driver.rs b/sericom-core/src/screen/process/driver.rs index e68e0b4..c58f594 100644 --- a/sericom-core/src/screen/process/driver.rs +++ b/sericom-core/src/screen/process/driver.rs @@ -1,8 +1,11 @@ use crossterm::style::Attributes; -use tracing::trace; +use tracing::{debug, trace}; use super::{C0, C1, CSI, ColorState, CsiKind, ParserEvent, SEMI, digits_to_int, process_colors}; -use crate::screen::{Cell, Cursor as _, Line, ScreenBuffer}; +use crate::screen::{ + Cell, Cursor as _, Line, ScreenBuffer, + process::{EDKind, ELKind}, +}; /// The layer between incoming [`ParserEvent`]s and the [`ScreenBuffer`]. pub struct ScreenDriver<'a> { @@ -21,7 +24,7 @@ impl<'a> ScreenDriver<'a> { } /// Returns the number of newlines written - pub fn process_events<'b>(&mut self, events: Vec>) -> u32 { + pub fn process_events(&mut self, events: Vec>) -> u32 { let span = tracing::trace_span!("process"); let _enter = span.enter(); @@ -29,7 +32,7 @@ impl<'a> ScreenDriver<'a> { for event in events { trace!(%event); match event { - ParserEvent::Text(bytes) => self.write_text(&bytes), + ParserEvent::Text(bytes) => self.write_text(bytes), ParserEvent::CSI(csi) => self.handle_csi(csi), ParserEvent::C1(c1) => self.handle_c1(c1), ParserEvent::C0(c0) => { @@ -85,7 +88,7 @@ impl<'a> ScreenDriver<'a> { self.buffer.cursor.tab(); } C0::CR => self.buffer.set_cursor_col(0), - _ => {} + other => debug!("recieved unsupported C0: {:?}", other), } } @@ -95,11 +98,11 @@ impl<'a> ScreenDriver<'a> { let int = digits_to_int(csi.params).unwrap_or(1); self.buffer.move_cursor_up(int); } - CsiKind::CursorDown => { + CsiKind::CursorDown | CsiKind::LineRel => { let int = digits_to_int(csi.params).unwrap_or(1); self.buffer.move_cursor_down(int); } - CsiKind::CursorForward => { + CsiKind::CursorForward | CsiKind::CharRel => { let int = digits_to_int(csi.params).unwrap_or(1); self.buffer.move_cursor_right(int); } @@ -117,11 +120,11 @@ impl<'a> ScreenDriver<'a> { self.buffer.move_cursor_up(int); self.buffer.set_cursor_col(0); } - CsiKind::CharAbsCHA => { + CsiKind::CharAbsCHA | CsiKind::CharAbsHPA => { let int = digits_to_int(csi.params).unwrap_or(1); self.buffer.set_cursor_col(int); } - CsiKind::PositionCUP => { + CsiKind::PositionCUP | CsiKind::PositionHVP => { if csi.params.is_empty() { self.buffer.set_cursor_pos((1u16, 1u16)); return; @@ -140,32 +143,81 @@ impl<'a> ScreenDriver<'a> { let int = digits_to_int(csi.params).unwrap_or(1); self.buffer.tab(int); } - CsiKind::EraseScreen => todo!(), - CsiKind::EraseLine => todo!(), - CsiKind::InsertLine => todo!(), - CsiKind::DeleteLine => todo!(), - CsiKind::DeleteChars => todo!(), - CsiKind::ScrollUp => todo!(), - CsiKind::ScrollDown => todo!(), - CsiKind::EraseChars => todo!(), + CsiKind::EraseInDisplay => self + .buffer + .erase_in_display(EDKind::try_from(csi.params).unwrap_or_default()), + CsiKind::EraseInLine => self + .buffer + .erase_in_line(ELKind::try_from(csi.params).unwrap_or_default()), + CsiKind::InsertLine => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.insert_lines(int); + } + CsiKind::DeleteLine => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.delete_lines(int); + } + CsiKind::DeleteChars => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.delete_chars(int); + } + CsiKind::ScrollUp => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.scroll_up(int); + } + CsiKind::ScrollDown | CsiKind::ScrollDown1991 => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.scroll_down(int); + } + CsiKind::EraseChars => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.erase_chars(int); + } CsiKind::BackTab => { let int = digits_to_int(csi.params).unwrap_or(1); self.buffer.rtab(int); } - CsiKind::CharRel => todo!(), - CsiKind::Rep => todo!(), - CsiKind::LineAbs => todo!(), - CsiKind::LineRel => todo!(), - CsiKind::PositionHVP => todo!(), - CsiKind::CharAbsHPA => todo!(), - CsiKind::Sgr => { + CsiKind::Rep => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.with_current_line(|line, cursor| { + let last_char = { + // if last_char is intended to be ' ' then this will break + let i = line.last_filled_idx(); + line.iter() + .flatten() + .nth(i) + .expect("idx is always a cell") + .character + }; + line.iter_mut() + .flatten() + .skip(usize::from(cursor.x - 1)) + .take(usize::from(int)) + .for_each(|c| c.character = last_char); + }); + } + CsiKind::LineAbs => { + let int = digits_to_int(csi.params).unwrap_or(1); + self.buffer.set_cursor_row(int); + } + CsiKind::SGR => { process_colors(csi.params, &mut self.color_state, &mut self.attrs); self.buffer .handle_span_colors(&self.color_state, self.attrs); } - CsiKind::ScrollDown1991 => todo!(), } } - fn handle_c1(&mut self, c1: C1) {} + fn handle_c1(&mut self, c1: C1) { + match c1 { + C1::IND | C1::DECFI => self.buffer.move_cursor_down(1), + C1::RI | C1::DECBI => self.buffer.move_cursor_up(1), + C1::NEL => { + self.buffer.move_cursor_down(1); + self.buffer.set_cursor_col(1); + } + C1::RIS => self.buffer.erase_in_display(EDKind::All), + other => debug!("recieved unsupported C1: {:?}", other), + } + } } diff --git a/sericom-core/src/screen/process/mod.rs b/sericom-core/src/screen/process/mod.rs index 757277d..8976bff 100644 --- a/sericom-core/src/screen/process/mod.rs +++ b/sericom-core/src/screen/process/mod.rs @@ -1,15 +1,12 @@ mod colors; -mod cursor; mod driver; mod parser; mod screen; mod xterm; pub use colors::{ColorState, process_colors}; -pub use cursor::process_cursor; pub use driver::ScreenDriver; pub use parser::{ByteParser, ParseState, ParserEvent}; -pub use screen::process_erase; pub use xterm::*; pub const fn digits_to_int(body: &[u8]) -> Option { diff --git a/sericom-core/src/screen/process/parser.rs b/sericom-core/src/screen/process/parser.rs index 6933062..a08eaa8 100644 --- a/sericom-core/src/screen/process/parser.rs +++ b/sericom-core/src/screen/process/parser.rs @@ -8,7 +8,7 @@ pub enum ParserEvent<'a> { C1(C1), } -impl<'a> std::fmt::Display for ParserEvent<'a> { +impl std::fmt::Display for ParserEvent<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Text(items) => { @@ -103,7 +103,7 @@ impl ByteParser { None => events.push(ParserEvent::CSI(CSI { kind, params: &[] })), } self.state = ParseState::Normal; - } else { + } else if self.start_idx.is_none() { self.start_idx = Some(idx); } } @@ -121,3 +121,58 @@ impl ByteParser { events } } + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! assert_event { + ($lhs:expr, $text:literal) => { + assert_eq!($lhs, ParserEvent::Text($text)) + }; + ($lhs:expr, c0 = $c0:path) => { + assert_eq!($lhs, ParserEvent::C0($c0)) + }; + ($lhs:expr, c1 = $c1:path) => { + assert_eq!($lhs, ParserEvent::C1($c1)) + }; + ($lhs:expr, $kind:path, $params:expr) => { + assert_eq!( + $lhs, + ParserEvent::CSI(CSI { + kind: $kind, + params: $params + }) + ) + }; + } + + #[test] + fn parser_basic() { + let mut parser = ByteParser::new(); + let bytes = b"\x1b[HI should be home now at 1,1\n\ + This is now the second linr, oops lets change that\x1b[24Gline\x1b[E\ + This should now be the third line. Lets do the next in blue and bold.\n\ + \x1b[1;34mAm I blue now??\x1b[0m\ + "; + let parsed = parser.feed(bytes); + assert_event!(parsed[0], CsiKind::PositionCUP, &[]); + assert_event!(parsed[1], b"I should be home now at 1,1"); + assert_event!(parsed[2], c0 = C0::NL); + assert_event!( + parsed[3], + b"This is now the second linr, oops lets change that" + ); + assert_event!(parsed[4], CsiKind::CharAbsCHA, b"24"); + assert_event!(parsed[5], b"line"); + assert_event!(parsed[6], CsiKind::NextLine, &[]); + assert_event!( + parsed[7], + b"This should now be the third line. Lets do the next in blue and bold." + ); + assert_event!(parsed[8], c0 = C0::NL); + assert_event!(parsed[9], CsiKind::SGR, b"1;34"); + assert_event!(parsed[10], b"Am I blue now??"); + assert_event!(parsed[11], CsiKind::SGR, b"0"); + } +} diff --git a/sericom-core/src/screen/process/screen.rs b/sericom-core/src/screen/process/screen.rs index 34d1262..8b13789 100644 --- a/sericom-core/src/screen/process/screen.rs +++ b/sericom-core/src/screen/process/screen.rs @@ -1,91 +1 @@ -use std::ops::Range; -use crate::screen::{Line, Position, ScreenBuffer, TermPos, TranslatePos}; - -#[expect(clippy::cast_possible_truncation)] -pub fn process_erase(seq: &[u8], kind: u8, sb: &mut ScreenBuffer) { - let body = &seq[2..seq.len() - 1]; - - match (kind, body) { - // erase from cursor until end of screen - (b'J', [] | [b'0']) => { - let buff_range = Range { - start: (sb.to_buff(sb.cursor).y + 1) as usize, - end: (sb.buff_rect().bottom() + 1) as usize, - }; - - sb.clear_line_from_cursor(); - sb.clear_lines(buff_range); - } - // erase from cursor to beginning of screen - (b'J', [b'1']) => { - let buff_range = Range { - start: sb.view_start as usize, - end: sb.to_buff(sb.cursor).y as usize, - }; - - sb.clear_lines(buff_range); - sb.clear_line_to_cursor(); - } - // erase entire screen - move cursor to ORIGIN - // erase saved lines - same as erase entire screen - // to preserve user scrollback history - (b'J', [b'2' | b'3']) => { - sb.lines.push_back(Line::new_empty(usize::from(sb.width()))); - // Casting to u32 from usize is fine because sb.lines should never - // exceed MAX_SCROLLBACK which is < usize::MAX - sb.view_start = (sb.lines.len() as u32).saturating_sub(1); - sb.cursor = Position::::ORIGIN; - } - // erase from cursor until end of line - (b'K', [] | [b'0']) => sb.clear_line_from_cursor(), - // erase start of line to the cursor - (b'K', [b'1']) => sb.clear_line_to_cursor(), - // erase the entire line - (b'K', [b'2']) => { - sb.with_current_line(|line, _| { - line.iter_mut().flatten().for_each(|cell| { - cell.character = b' '; - }); - }); - } - _ => {} - } -} - -impl ScreenBuffer { - pub(crate) fn clear_line_to_cursor(&mut self) { - self.with_current_line(|line, cursor| { - for (idx, cell) in line.iter_mut().flatten().enumerate() { - if idx < usize::from(cursor.x) { - cell.character = b' '; - } - } - }); - } - - pub(crate) fn clear_line_from_cursor(&mut self) { - self.with_current_line(|line, cursor| { - line.iter_mut() - .flatten() - .skip(usize::from(cursor.x)) - .for_each(|cell| { - cell.character = b' '; - }); - }); - } - - pub(crate) fn clear_lines(&mut self, range: Range) { - for line in self.lines.range_mut(range) { - line.iter_mut().flatten().for_each(|cell| { - cell.character = b' '; - }); - } - } -} - -pub fn _process_screen(seq: &[u8], _kind: u8, _sb: &mut ScreenBuffer) { - let _body = &seq[2..seq.len() - 1]; - // ESC[={value}h Changes the screen width or type to the mode specified by value - todo!() -} diff --git a/sericom-core/src/screen/process/xterm.rs b/sericom-core/src/screen/process/xterm.rs index 0606cde..53a38ab 100644 --- a/sericom-core/src/screen/process/xterm.rs +++ b/sericom-core/src/screen/process/xterm.rs @@ -187,6 +187,7 @@ impl std::fmt::Display for C1 { def_xterm_tf! { #[repr(u8)] #[non_exhaustive] + #[allow(clippy::doc_markdown)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CsiKind { /// Cursor Up _Ps_ Times 'A' (default = 1) (CUU) @@ -211,12 +212,12 @@ def_xterm_tf! { /// /// VT100: CSI _Ps_ J (ED) /// VT220: CSI ? _Ps_ J (DECSED) - EraseScreen = b'J', + EraseInDisplay = b'J', /// Erase in Line 'K' /// /// VT100: CSI _Ps_ K (EL) /// VT220: CSI ? _Ps_ K (DECSEL) - EraseLine = b'K', + EraseInLine = b'K', /// Insert _Ps_ Line(s) 'L' (default = 1) (IL) InsertLine = b'L', /// Delete _Ps_ Line(s) 'M' (default = 1) (DL) @@ -242,7 +243,7 @@ def_xterm_tf! { /// Horizontal and Vertical Position [row;column] 'f' (default = [1,1]) (HVP) PositionHVP = b'f', /// Character Attributes 'm' (SGR) - Sgr = b'm', + SGR = b'm', /// Character Position Absolute [column] '`' (default = [row,1]) (HPA) CharAbsHPA = b'`', /// Scroll down _Ps_ lines '^' (default = 1) (SD) @@ -269,7 +270,7 @@ pub struct CSI<'a> { pub params: &'a [u8], } -impl<'a> std::fmt::Display for CSI<'a> { +impl std::fmt::Display for CSI<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, @@ -281,3 +282,58 @@ impl<'a> std::fmt::Display for CSI<'a> { ) } } + +def_xterm_tf! { + #[repr(u8)] + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] + #[doc = "Erase in Display (ED) kinds"] + pub enum EDKind { + #[default] + Below = b'0', + Above = b'1', + All = b'2', + Saved = b'3' + } + error = "Could not parse {:#X?} as EDKind" +} + +def_xterm_tf! { + #[repr(u8)] + #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)] + #[doc = "Erase in Line (EL) kinds"] + pub enum ELKind { + #[default] + Right = b'0', + Left = b'1', + All = b'2', + } + error = "Could not parse {:#X?} as ELKind" +} + +impl TryFrom<&[u8]> for EDKind { + type Error = crate::SeriError; + + fn try_from(value: &[u8]) -> Result { + if value.is_empty() { + Err(crate::SeriError::Parsing( + "Could not parse as EDKind".to_string(), + )) + } else { + Self::try_from(value[0]) + } + } +} + +impl TryFrom<&[u8]> for ELKind { + type Error = crate::SeriError; + + fn try_from(value: &[u8]) -> Result { + if value.is_empty() { + Err(crate::SeriError::Parsing( + "Could not parse as ELKind".to_string(), + )) + } else { + Self::try_from(value[0]) + } + } +} diff --git a/sericom-core/src/screen/rect.rs b/sericom-core/src/screen/rect.rs index 4cf91e1..0cfb7fd 100644 --- a/sericom-core/src/screen/rect.rs +++ b/sericom-core/src/screen/rect.rs @@ -51,8 +51,8 @@ impl Rect { impl Rect { /// The area (W x H) of [`Rect`] #[must_use] - pub const fn area(&self) -> u32 { - self.width as u32 * self.height as u32 + pub const fn area(&self) -> Option { + (self.width as u32).checked_mul(self.height as u32) } #[must_use] diff --git a/sericom-core/src/screen/render.rs b/sericom-core/src/screen/render.rs index 3e27193..41604a3 100644 --- a/sericom-core/src/screen/render.rs +++ b/sericom-core/src/screen/render.rs @@ -3,7 +3,7 @@ use crossterm::style::{Attributes, Color, Colors}; use std::io::BufWriter; use tracing::instrument; -use super::{ByteParser, Cell, Line, ParserEvent, Span, process::NL}; +use super::{ByteParser, Cell, Line, ParserEvent, Span}; use super::{Cursor, ScreenBuffer, UIAction}; use crate::configs::get_config; diff --git a/sericom-core/src/screen/tests/mod.rs b/sericom-core/src/screen/tests/mod.rs index 8035b04..655f907 100644 --- a/sericom-core/src/screen/tests/mod.rs +++ b/sericom-core/src/screen/tests/mod.rs @@ -3,7 +3,7 @@ mod components; mod cursor; mod escape; -pub use crate::screen::{driver::ScreenDriver, *}; +pub use crate::screen::{ScreenDriver, *}; pub use crossterm::style::{Attribute, Attributes, Color}; pub use std::collections::VecDeque; diff --git a/sericom-core/src/screen/ui_command.rs b/sericom-core/src/screen/ui_command.rs index 1d7efed..59b7c55 100644 --- a/sericom-core/src/screen/ui_command.rs +++ b/sericom-core/src/screen/ui_command.rs @@ -78,7 +78,7 @@ impl UIAction for ScreenBuffer { /// and `screen_y` is the y-position (line) of the start of the selection. fn start_selection(&mut self, pos: Position) { use super::TranslatePos; - self.to_buff(pos); + self.to_buff(&pos); self.clear_selection(); // self.selection_start = Some((pos.x, absolute_line)); // self.needs_render = true; @@ -88,7 +88,7 @@ impl UIAction for ScreenBuffer { /// Where `screen_x` is the x-position and `screen_y` is the y-position (line). fn update_selection(&mut self, pos: Position) { use super::TranslatePos; - self.to_buff(pos); + self.to_buff(&pos); // self.selection_end = Some((pos.x, absolute_line)); self.update_selection_highlighting(); // self.needs_render = true; From d0bebbe1d705ac6b08813f240f6db003f0cfeb48 Mon Sep 17 00:00:00 2001 From: Thomas Katter Date: Wed, 24 Dec 2025 00:07:17 -0600 Subject: [PATCH 40/40] test: Testing the handling of CSI/C0/C1 sequences Converted the Position to be 1-based (1,1) is ORIGIN since that is what control sequences expect. Tested a majority of the new implementation and fixed things as a result. Still a few things to test like scrolling and ensuring the view into the ScreenBuffer's buffer is up-to-date. Next thing on the agenda is to wire up the interactive session. changelog: ignore --- sericom-core/src/configs/mod.rs | 4 +- sericom-core/src/screen/buffer.rs | 54 ++- sericom-core/src/screen/components/line.rs | 58 ++- sericom-core/src/screen/components/mod.rs | 6 - sericom-core/src/screen/position.rs | 91 ++--- sericom-core/src/screen/process/colors.rs | 6 +- sericom-core/src/screen/process/driver.rs | 19 +- sericom-core/src/screen/process/mod.rs | 2 +- sericom-core/src/screen/process/parser.rs | 55 --- sericom-core/src/screen/process/screen.rs | 1 - sericom-core/src/screen/tests/colors.rs | 44 +-- sericom-core/src/screen/tests/cursor.rs | 415 ++++++++++++++++++--- sericom-core/src/screen/tests/escape.rs | 35 +- sericom-core/src/screen/tests/mod.rs | 44 ++- sericom-core/src/screen/tests/parsing.rs | 31 ++ sericom-core/src/serial_actor/mod.rs | 18 +- sericom-core/src/session/handle.rs | 81 +--- sericom-core/src/session/mod.rs | 37 -- 18 files changed, 605 insertions(+), 396 deletions(-) delete mode 100644 sericom-core/src/screen/process/screen.rs create mode 100644 sericom-core/src/screen/tests/parsing.rs diff --git a/sericom-core/src/configs/mod.rs b/sericom-core/src/configs/mod.rs index f438284..759d43c 100644 --- a/sericom-core/src/configs/mod.rs +++ b/sericom-core/src/configs/mod.rs @@ -55,10 +55,10 @@ impl Config { } } -// #[cfg(test)] +#[cfg(test)] static INIT: std::sync::Once = std::sync::Once::new(); -// #[cfg(test)] +#[cfg(test)] pub fn init_for_tests() { INIT.call_once(|| { CONFIG diff --git a/sericom-core/src/screen/buffer.rs b/sericom-core/src/screen/buffer.rs index 3a95372..f63637f 100644 --- a/sericom-core/src/screen/buffer.rs +++ b/sericom-core/src/screen/buffer.rs @@ -1,6 +1,5 @@ use crossterm::style::Attributes; use std::collections::VecDeque; -use tracing::trace; use crate::screen::process::{EDKind, ELKind}; use crate::screen::{BuffPos, Cell, UICommand}; @@ -79,7 +78,10 @@ impl ScreenBuffer { } pub(crate) fn handle_span_colors(&mut self, colors: &super::ColorState, attrs: Attributes) { - let curr_col = self.cursor.x.into(); + let curr_col = { + let c = self.to_buff(&self.cursor); + c.x.into() + }; let line = self.curr_line_mut(); line.split_spans(colors, attrs, curr_col); @@ -119,8 +121,12 @@ impl ScreenBuffer { .get_mut(pos_in_lines.y as usize) .expect("verified that line exists") } else { - self.push_line(Line::new_empty(self.width() as usize)); - self.lines.back_mut().expect("is not empty") + for _ in self.lines.len()..=pos_in_lines.y as usize { + self.push_line(Line::new_empty(self.width() as usize)); + } + self.lines + .get_mut(pos_in_lines.y as usize) + .expect("is not empty") } } @@ -138,22 +144,14 @@ impl ScreenBuffer { let buff_rect = self.buff_rect(); let num_lines = self.lines.len(); - let Some(cmd) = update else { - if (self.view_start <= num_lines as u32) && ((num_lines as u32) < buff_rect.height) { - trace!(%num_lines, height=%buff_rect.height, view_start=%self.view_start, "lines is within buff_rect"); + let Some(_cmd) = update else { + // Return because still filling up the initial/empty screen 0..TERM_HEIGHT + if num_lines <= (self.view_start + buff_rect.height) as usize { return; - } else if num_lines as u32 > buff_rect.height { - // num_lines - 1 because need to be in indexing terms (0 base) - let additional = (num_lines as u32 - 1) - (buff_rect.height + self.view_start); - self.view_start += additional; - trace!( - %num_lines, - height=%buff_rect.height, - view_start=%self.view_start, - %additional, - "lines is greater than buff_rect" - ); } + + let additional = (num_lines as u32 - (self.view_start + buff_rect.height)).max(1); + self.view_start += additional; return; }; @@ -171,7 +169,7 @@ impl ScreenBuffer { self.lines.pop_back(); } } - self.cursor.x = 0; + self.cursor.x = 1; } pub(crate) fn delete_lines(&mut self, num: u16) { @@ -181,7 +179,7 @@ impl ScreenBuffer { self.lines .push_back(Line::new_empty(usize::from(self.width()))); } - self.cursor.x = 0; + self.cursor.x = 1; } #[allow(clippy::cast_possible_truncation)] @@ -232,18 +230,16 @@ impl ScreenBuffer { } } - /// TODO: TEST ME pub(crate) fn erase_chars(&mut self, num: u16) { self.with_current_line(|line, cursor| { line.iter_mut() .flatten() - .skip(usize::from(cursor.x - 1)) + .skip(usize::from(cursor.x)) .take(usize::from(num)) .for_each(|c| c.character = b' '); }); } - /// TODO: TEST ME pub(crate) fn delete_chars(&mut self, num: u16) { self.with_current_line(|line, cursor| { let mut ttl_del = 0; @@ -252,7 +248,7 @@ impl ScreenBuffer { if let Some(span) = line.get_mut_span(0) { ttl_del += span .cells - .drain(usize::from(cursor.x)..=usize::from(num)) + .drain(usize::from(cursor.x)..usize::from(cursor.x + num)) .len(); span.cells.resize(span.len() + ttl_del, Cell::EMPTY); } @@ -264,12 +260,12 @@ impl ScreenBuffer { if let Some(span) = line.get_mut_span(start_span) { ttl_del += span .cells - .drain(usize::from(cursor.x)..=usize::from(num)) + .drain(usize::from(cursor.x)..usize::from(cursor.x + num)) .len(); span.cells.shrink_to(span.len()); } - if let Some(span) = line.get_mut_span(end_span) { + if let Some(span) = line.get_mut_span(start_span + 1) { span.cells.resize(span.len() + ttl_del, Cell::EMPTY); } } else if end_span - start_span == 1 { @@ -279,7 +275,7 @@ impl ScreenBuffer { } if let Some(span) = line.get_mut_span(end_span) { - ttl_del += span.cells.drain(..=end_off).len(); + ttl_del += span.cells.drain(..end_off).len(); span.cells.resize(span.len() + ttl_del, Cell::EMPTY); } } else { @@ -293,8 +289,8 @@ impl ScreenBuffer { ttl_del += line.0.remove(span_between).cells.len(); } - if let Some(span) = line.get_mut_span(end_span) { - ttl_del += span.cells.drain(..=end_off).len(); + if let Some(span) = line.get_mut_span(start_span + 1) { + ttl_del += span.cells.drain(..end_off).len(); span.cells.resize(span.len() + ttl_del, Cell::EMPTY); } } diff --git a/sericom-core/src/screen/components/line.rs b/sericom-core/src/screen/components/line.rs index 18de871..a07b554 100644 --- a/sericom-core/src/screen/components/line.rs +++ b/sericom-core/src/screen/components/line.rs @@ -71,8 +71,8 @@ impl Line { /// generally the desired behavior. #[must_use] pub fn filled_cells(&self) -> usize { - let last = self.last_filled_idx() + 1; - if self.num_cells() == last { 0 } else { last } + let last = self.last_filled_idx(); + if last == 0 { 0 } else { last + 1 } } /// The total number of [`Cell`]s in `self`. @@ -95,7 +95,7 @@ impl Line { .rev() .position(|c| c.character != b' ') .unwrap_or(0); - if len - p == len { len - 1 } else { len - p } + if len - p == len { len - 1 } else { len - p - 1 } } fn all_whitespace(&self) -> bool { @@ -129,12 +129,22 @@ impl Line { pub fn ascii_bytes(&self) -> impl Iterator + '_ { use std::ops::Deref; - let end = self.last_filled_idx(); - self.iter() - .flatten() - .take(end + 1) - .map(Deref::deref) - .copied() + let end = { + let last = self.last_filled_idx(); + if last == 0 && *self.0[0].cells[0] == b' ' { + return EitherIter::Left(std::iter::once(b'\n')); + } + last + 1 + }; + + EitherIter::Right( + self.iter() + .flatten() + .take(end) + .map(Deref::deref) + .copied() + .chain(std::iter::once(b'\n')), + ) } /// Splits the [`Span`] at `col` and applies `colors` && `attrs` to the new [`Span`]. @@ -257,3 +267,33 @@ impl std::fmt::Debug for Line { f.write_str(&s) } } + +/// A small helper enum - literally only for short-circuiting `Line::ascii_bytes` +enum EitherIter { + Left(L), + Right(R), +} + +impl Iterator for EitherIter +where + L: Iterator, + R: Iterator, +{ + type Item = T; + + #[inline] + fn next(&mut self) -> Option { + match self { + Self::Left(l) => l.next(), + Self::Right(r) => r.next(), + } + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + match self { + Self::Left(l) => l.size_hint(), + Self::Right(r) => r.size_hint(), + } + } +} diff --git a/sericom-core/src/screen/components/mod.rs b/sericom-core/src/screen/components/mod.rs index 739487f..1e5e00e 100644 --- a/sericom-core/src/screen/components/mod.rs +++ b/sericom-core/src/screen/components/mod.rs @@ -5,9 +5,3 @@ mod span; pub use cell::Cell; pub use line::Line; pub use span::Span; - -#[macro_export] -#[doc(hidden)] -macro_rules! csi { - ($( $l:expr ),*) => { concat!("\x1B[", $( $l ),*) }; -} diff --git a/sericom-core/src/screen/position.rs b/sericom-core/src/screen/position.rs index 379db4c..c3a7873 100644 --- a/sericom-core/src/screen/position.rs +++ b/sericom-core/src/screen/position.rs @@ -45,13 +45,14 @@ pub struct Position { impl Position { pub const ORIGIN: Self = Self { - x: 0, - y: 0, + x: 1, + y: 1, _phantom: PhantomData, }; } impl Position { + /// Forward tab _n_ times. pub const fn tabn(&mut self, n: u16) { let mut acc = 0; while acc < n { @@ -59,6 +60,8 @@ impl Position { acc += 1; } } + + /// Reverse tab _n_ times. pub const fn rtabn(&mut self, n: u16) { let mut acc = 0; while acc < n { @@ -66,31 +69,41 @@ impl Position { acc += 1; } } + + /// Forward tab. pub const fn tab(&mut self) { - let tab = TAB_WIDTH - (self.x % TAB_WIDTH); + let tab = TAB_WIDTH - ((self.x - 1) % TAB_WIDTH); self.x += tab; } + + /// Reverse tab. pub const fn rtab(&mut self) { let tab = TAB_WIDTH - (self.x % TAB_WIDTH); - self.x.saturating_sub(tab); + self.x = self.x.saturating_sub(tab); } + pub const fn set_y(&mut self, y: PosY) { self.y = y; } + pub const fn set_x(&mut self, x: u16) { self.x = x; } + pub fn set_pos_from>(&mut self, pos: P) { let p = pos.into(); self.x = p.x; self.y = p.y; } + pub const fn set(&mut self, pos: Self) { *self = pos; } + pub const fn x(&self) -> u16 { self.x } + pub const fn y(&self) -> PosY { self.y } @@ -210,13 +223,12 @@ impl Cursor for ScreenBuffer { fn tab(&mut self, n: u16) { let bounds = self.bounds(); self.cursor.tabn(n); - if self.cursor.x > bounds.right() { - self.cursor.x = bounds.right(); - } + self.cursor.x = self.cursor.x.min(bounds.right()); } fn rtab(&mut self, n: u16) { - self.cursor.tabn(n); + self.cursor.rtabn(n); + self.cursor.x = self.cursor.x.max(1); } fn set_cursor_pos

(&mut self, position: P) @@ -225,63 +237,41 @@ impl Cursor for ScreenBuffer { { let bounds = self.bounds(); let mut new_pos: Position = position.into(); - if new_pos.x > bounds.right() { - new_pos.x = bounds.right(); - } - if new_pos.y > bounds.bottom() { - new_pos.y = bounds.bottom(); - } + + new_pos.x = new_pos.x.clamp(1, bounds.right()); + new_pos.y = new_pos.y.clamp(1, bounds.bottom()); self.cursor.set_pos_from(new_pos); } fn move_cursor_left(&mut self, cells: u16) { - self.cursor.x = self.cursor.x.saturating_sub(cells); + self.cursor.x = self.cursor.x.saturating_sub(cells).max(1); } fn move_cursor_right(&mut self, cells: u16) { let bounds = self.bounds(); - let mut new_x = self.cursor.x.saturating_add(cells); - - if new_x > bounds.right() { - new_x = bounds.right(); - } - self.cursor.x = new_x; + self.cursor.x = self.cursor.x.saturating_add(cells).min(bounds.right()); } fn move_cursor_up(&mut self, lines: u16) { - self.cursor.y = self.cursor.y.saturating_sub(lines); + self.cursor.y = self.cursor.y.saturating_sub(lines).max(1); } - // TODO: Figure out line pushes as cursor moves down fn move_cursor_down(&mut self, lines: u16) { let bounds = self.bounds(); - let mut new_y = self.cursor.y.saturating_add(lines); - // If the cursor would pass the bottom scroll margin, it will stop there // [xterm.js](https://xtermjs.org/docs/api/vtfeatures/) - if new_y > bounds.bottom() { - new_y = bounds.bottom(); - } - self.cursor.y = new_y; + self.cursor.y = self.cursor.y.saturating_add(lines).min(bounds.bottom()); } fn set_cursor_col(&mut self, col: u16) { let bounds = self.bounds(); - if col > bounds.right() { - self.cursor.x = bounds.right(); - } else { - self.cursor.x = col; - } + self.cursor.x = col.clamp(1, bounds.right()); } fn set_cursor_row(&mut self, row: u16) { let bounds = self.bounds(); - if row > bounds.bottom() { - self.cursor.y = bounds.bottom(); - } else { - self.cursor.y = row; - } + self.cursor.y = row.clamp(1, bounds.bottom()); } } @@ -293,23 +283,34 @@ pub trait TranslatePos { impl TranslatePos for ScreenBuffer { fn to_term(&self, pos: &Position) -> Position { let buff_win = self.buff_rect(); - let visible_y = pos.y.clamp(buff_win.top(), buff_win.bottom()); // Casting is fine because the viewport (buff_win) is the size of // a user's terminal and visible_y - buff_win.top() simply returns // a number somewhere within the height of the terminal #[allow(clippy::cast_possible_truncation)] - let term_y = (visible_y - buff_win.top()) as u16; - - let term_x = pos.x.clamp(buff_win.left(), buff_win.right()); + let term_y = ((visible_y - buff_win.top()) as u16).clamp(1, buff_win.height as u16); + let term_x = pos.x.clamp(1, buff_win.right()); Position::::from((term_x, term_y)) } fn to_buff(&self, pos: &Position) -> Position { - let buff_y: u32 = self.view_start + u32::from(pos.y); - let buff_x = pos.x.clamp(0, self.width()); + let buff_y: u32 = { + if pos.y == 1 { + self.view_start + } else { + self.view_start + u32::from(pos.y - 1) + } + }; + + let buff_x = { + if pos.x == 1 { + 0 + } else { + pos.x.clamp(0, self.width()).saturating_sub(1).max(1) + } + }; Position::::from((buff_x, buff_y)) } diff --git a/sericom-core/src/screen/process/colors.rs b/sericom-core/src/screen/process/colors.rs index de47b72..71962ff 100644 --- a/sericom-core/src/screen/process/colors.rs +++ b/sericom-core/src/screen/process/colors.rs @@ -47,15 +47,15 @@ impl ColorState { use tracing::debug; self.colors = if let Some(colored) = Colored::parse_ansi(ascii_str) { - eprintln!("{colored:#?}"); + debug!("{colored:#?}"); self.colors.then(&colored.into()) } else { if let Some(color) = Color::parse_ansi(ascii_str) { - debug!(target: "parser::colors", "Second try got: {color:#?}"); + debug!("Second try got: {color:#?}"); } else { debug!("Failed to parse ascii_str second time"); } - debug!(target: "parser::colors", "Failed to parse ascii_str"); + debug!("Failed to parse ascii_str"); self.colors }; } diff --git a/sericom-core/src/screen/process/driver.rs b/sericom-core/src/screen/process/driver.rs index c58f594..e034b7c 100644 --- a/sericom-core/src/screen/process/driver.rs +++ b/sericom-core/src/screen/process/driver.rs @@ -63,20 +63,8 @@ impl<'a> ScreenDriver<'a> { match c0 { C0::BS => self.buffer.move_cursor_left(1), C0::NL => { - self.buffer.with_current_line(|line, _| { - // pushing to the end of line unconditionally because - // NL is always the end of a line, and if received a CR - // before NL, then `with_current_span` would behave incorrect - if let Some(span) = line.0.last_mut() { - let last = span.last_filled_idx(); - span.cells - .get_mut(last) - .expect("span len is greater than last filled cell") - .character = b'\n'; - } - }); - self.buffer.set_cursor_col(0); self.buffer.move_cursor_down(1); + self.buffer.set_cursor_col(1); self.buffer .push_line(Line::new_empty(self.buffer.width() as usize)); self.buffer.update_view(None); @@ -87,11 +75,12 @@ impl<'a> ScreenDriver<'a> { }); self.buffer.cursor.tab(); } - C0::CR => self.buffer.set_cursor_col(0), + C0::CR => self.buffer.set_cursor_col(1), other => debug!("recieved unsupported C0: {:?}", other), } } + #[allow(clippy::too_many_lines)] fn handle_csi(&mut self, csi: CSI) { match csi.kind { CsiKind::CursorUp => { @@ -191,7 +180,7 @@ impl<'a> ScreenDriver<'a> { }; line.iter_mut() .flatten() - .skip(usize::from(cursor.x - 1)) + .skip(usize::from(cursor.x)) .take(usize::from(int)) .for_each(|c| c.character = last_char); }); diff --git a/sericom-core/src/screen/process/mod.rs b/sericom-core/src/screen/process/mod.rs index 8976bff..238e184 100644 --- a/sericom-core/src/screen/process/mod.rs +++ b/sericom-core/src/screen/process/mod.rs @@ -1,7 +1,6 @@ mod colors; mod driver; mod parser; -mod screen; mod xterm; pub use colors::{ColorState, process_colors}; @@ -9,6 +8,7 @@ pub use driver::ScreenDriver; pub use parser::{ByteParser, ParseState, ParserEvent}; pub use xterm::*; +#[must_use] pub const fn digits_to_int(body: &[u8]) -> Option { if body.is_empty() { return None; diff --git a/sericom-core/src/screen/process/parser.rs b/sericom-core/src/screen/process/parser.rs index a08eaa8..cf84d79 100644 --- a/sericom-core/src/screen/process/parser.rs +++ b/sericom-core/src/screen/process/parser.rs @@ -121,58 +121,3 @@ impl ByteParser { events } } - -#[cfg(test)] -mod tests { - use super::*; - - macro_rules! assert_event { - ($lhs:expr, $text:literal) => { - assert_eq!($lhs, ParserEvent::Text($text)) - }; - ($lhs:expr, c0 = $c0:path) => { - assert_eq!($lhs, ParserEvent::C0($c0)) - }; - ($lhs:expr, c1 = $c1:path) => { - assert_eq!($lhs, ParserEvent::C1($c1)) - }; - ($lhs:expr, $kind:path, $params:expr) => { - assert_eq!( - $lhs, - ParserEvent::CSI(CSI { - kind: $kind, - params: $params - }) - ) - }; - } - - #[test] - fn parser_basic() { - let mut parser = ByteParser::new(); - let bytes = b"\x1b[HI should be home now at 1,1\n\ - This is now the second linr, oops lets change that\x1b[24Gline\x1b[E\ - This should now be the third line. Lets do the next in blue and bold.\n\ - \x1b[1;34mAm I blue now??\x1b[0m\ - "; - let parsed = parser.feed(bytes); - assert_event!(parsed[0], CsiKind::PositionCUP, &[]); - assert_event!(parsed[1], b"I should be home now at 1,1"); - assert_event!(parsed[2], c0 = C0::NL); - assert_event!( - parsed[3], - b"This is now the second linr, oops lets change that" - ); - assert_event!(parsed[4], CsiKind::CharAbsCHA, b"24"); - assert_event!(parsed[5], b"line"); - assert_event!(parsed[6], CsiKind::NextLine, &[]); - assert_event!( - parsed[7], - b"This should now be the third line. Lets do the next in blue and bold." - ); - assert_event!(parsed[8], c0 = C0::NL); - assert_event!(parsed[9], CsiKind::SGR, b"1;34"); - assert_event!(parsed[10], b"Am I blue now??"); - assert_event!(parsed[11], CsiKind::SGR, b"0"); - } -} diff --git a/sericom-core/src/screen/process/screen.rs b/sericom-core/src/screen/process/screen.rs deleted file mode 100644 index 8b13789..0000000 --- a/sericom-core/src/screen/process/screen.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sericom-core/src/screen/tests/colors.rs b/sericom-core/src/screen/tests/colors.rs index 42f4767..6fd76e2 100644 --- a/sericom-core/src/screen/tests/colors.rs +++ b/sericom-core/src/screen/tests/colors.rs @@ -56,14 +56,14 @@ fn test_basic_fg() { let cases = vec![ Case { - seq: b"\x1b[31m", + seq: b"31", expected_fg: Some(Color::DarkRed), expected_bg: Some(bg), expected_attrs: vec![], label: "FG basic red", }, Case { - seq: b"\x1b[37m", + seq: b"37", expected_fg: Some(Color::Grey), expected_bg: Some(bg), expected_attrs: vec![], @@ -81,7 +81,7 @@ fn test_basic_bg() { drop(config); let cases = vec![Case { - seq: b"\x1b[44m", + seq: b"44", expected_fg: Some(fg), expected_bg: Some(Color::DarkBlue), expected_attrs: vec![], @@ -100,14 +100,14 @@ fn test_bright_colors() { let cases = vec![ Case { - seq: b"\x1b[95m", + seq: b"95", expected_fg: Some(Color::Magenta), expected_bg: Some(bg), expected_attrs: vec![], label: "FG bright magenta", }, Case { - seq: b"\x1b[106m", + seq: b"106", expected_fg: Some(fg), expected_bg: Some(Color::Cyan), expected_attrs: vec![], @@ -127,21 +127,21 @@ fn test_resets_and_defaults() { let cases = vec![ Case { - seq: b"\x1b[0m", + seq: b"0", expected_fg: Some(fg), expected_bg: Some(bg), expected_attrs: vec![], label: "Reset", }, Case { - seq: b"\x1b[39m", + seq: b"39", expected_fg: Some(Color::Reset), expected_bg: Some(bg), expected_attrs: vec![], label: "Reset FG default", }, Case { - seq: b"\x1b[49m", + seq: b"49", expected_fg: Some(fg), expected_bg: Some(Color::Reset), expected_attrs: vec![], @@ -160,7 +160,7 @@ fn test_attributes() { drop(config); let cases = vec![Case { - seq: b"\x1b[1;3;4m", + seq: b"1;3;4", expected_fg: Some(fg), expected_bg: Some(bg), expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], @@ -179,14 +179,14 @@ fn test_256_color_palette() { let cases = vec![ Case { - seq: b"\x1b[38;5;196m", + seq: b"38;5;196", expected_fg: Some(Color::AnsiValue(196)), expected_bg: Some(bg), expected_attrs: vec![], label: "FG 256 red", }, Case { - seq: b"\x1b[48;5;27m", + seq: b"48;5;27", expected_fg: Some(fg), expected_bg: Some(Color::AnsiValue(27)), expected_attrs: vec![], @@ -206,7 +206,7 @@ fn test_truecolor_palette() { let cases = vec![ Case { - seq: b"\x1b[38;2;255;128;64m", + seq: b"38;2;255;128;64", expected_fg: Some(Color::Rgb { r: 255, g: 128, @@ -217,7 +217,7 @@ fn test_truecolor_palette() { label: "FG truecolor orange", }, Case { - seq: b"\x1b[48;2;10;20;30m", + seq: b"48;2;10;20;30", expected_fg: Some(fg), expected_bg: Some(Color::Rgb { r: 10, @@ -241,14 +241,14 @@ fn test_mix_attr_colors() { let cases = vec![ Case { - seq: b"\x1b[1;3;4;38;5;202m", + seq: b"1;3;4;38;5;202", expected_fg: Some(Color::AnsiValue(202)), expected_bg: Some(bg), expected_attrs: vec![Attribute::Bold, Attribute::Italic, Attribute::Underlined], label: "Bold + Italic + Underlined + FG 256 orange", }, Case { - seq: b"\x1b[5;7;48;2;128;64;200m", + seq: b"5;7;48;2;128;64;200", expected_fg: Some(fg), expected_bg: Some(Color::Rgb { r: 128, @@ -272,7 +272,7 @@ fn test_kitchen_sink() { let cases = vec![ Case { - seq: b"\x1b[1;3;38;5;202;4;48;2;10;20;30m", + seq: b"1;3;38;5;202;4;48;2;10;20;30", expected_fg: Some(Color::AnsiValue(202)), expected_bg: Some(Color::Rgb { r: 10, @@ -283,7 +283,7 @@ fn test_kitchen_sink() { label: "Bold + Italic + Underlined + FG 256 + BG truecolor", }, Case { - seq: b"\x1b[20;53m", + seq: b"20;53", expected_fg: Some(fg), expected_bg: Some(bg), expected_attrs: vec![Attribute::Fraktur, Attribute::OverLined], @@ -303,35 +303,35 @@ fn test_invalid() { let cases = vec![ Case { - seq: b"\x1b[38;5m", + seq: b"38;5", expected_fg: Some(fg), expected_bg: Some(bg), expected_attrs: vec![], label: "Incomplete 256 FG (missing index)", }, Case { - seq: b"\x1b[48;5;999m", + seq: b"48;5;999", expected_fg: Some(fg), expected_bg: Some(bg), expected_attrs: vec![], label: "Out-of-range 256 BG (999)", }, Case { - seq: b"\x1b[38;2;255;0m", + seq: b"38;2;255;0", expected_fg: Some(fg), expected_bg: Some(bg), expected_attrs: vec![], label: "Incomplete truecolor FG (missing B)", }, Case { - seq: b"\x1b[48;2;256;256;256m", + seq: b"48;2;256;256;256", expected_fg: Some(fg), expected_bg: Some(bg), expected_attrs: vec![], label: "Invalid RGB components (>255)", }, Case { - seq: b"\x1b[999m", + seq: b"999", expected_fg: Some(fg), expected_bg: Some(bg), expected_attrs: vec![], diff --git a/sericom-core/src/screen/tests/cursor.rs b/sericom-core/src/screen/tests/cursor.rs index d405e54..f20b0e4 100644 --- a/sericom-core/src/screen/tests/cursor.rs +++ b/sericom-core/src/screen/tests/cursor.rs @@ -1,43 +1,116 @@ use super::*; -use crate::{assert_line_eq, setup}; - -/// Bracket '[' -const BK: u8 = b'['; -/// Backspace -const BS: u8 = 0x08; -/// Carrige return '\r' -const CR: u8 = 0x0D; -/// Escape 'ESC' -const ESC: u8 = 0x1B; -/// Newline '\n' -const NL: u8 = 0x0A; -/// Escape sequence separator ';' -const SEP: u8 = b';'; -/// Tab '\t' -const TAB: u8 = 0x09; -/// Form feed -const FF: u8 = 0x0C; -/// Reset graphics mode escape sequence -const RESET: &[u8] = &[ESC, BK, b'0', b'm']; +use crate::assert_line_eq; +use crate::assert_span_eq; +use crate::csi; +use crate::setup; + +const WORDS: [&str; 4] = [ + "This is one line of words", + "This is a second line of words", + "This is a third line of words", + "This is a fourth line of words", +]; + +#[test] +fn tabs() { + setup!(sb, parser); + { + let s = format!( + "This is the first line\nThis is the second line\nThis is the third line\n{}Tabbed?", + csi!("I") + ); + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + + assert_eq!(sb.lines.len(), 4); + assert_eq!(sb.cursor, Position::::from((16u16, 4u16))); + assert_line_eq!(sb, 3, " Tabbed?"); + } + { + let s = format!( + "This is the first line\nThis is the second line\nThis is the third line\n{}Tabbed?", + csi!("3I") + ); + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + assert_eq!(sb.lines.len(), 7); + assert_eq!(sb.cursor, Position::::from((32u16, 7u16))); + assert_line_eq!(sb, 6, " Tabbed?"); + } + { + let s = format!("{}Reverse!", csi!("2Z")); + let parsed = parser.feed(s.as_bytes()); + let mut driver = ScreenDriver::new(&mut sb); + driver.process_events(parsed); + assert_eq!(sb.lines.len(), 7); + assert_eq!(sb.cursor, Position::::from((24u16, 7u16))); + assert_line_eq!(sb, 6, " Reverse! Tabbed?"); + } +} + +#[test] +fn cursor_nextline() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!("{}{}{}", WORDS[0], csi!("E"), WORDS[1]); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_line_eq!(sb, 0, "This is one line of words"); + assert_line_eq!(sb, 1, "This is a second line of words"); + assert_eq!(sb.cursor.x, 31); + assert_eq!(sb.cursor.y, 2); + let line = sb.curr_line().unwrap(); + assert_eq!(line.last_filled_idx(), 29); + assert_eq!(sb.lines.len(), 2); +} + +#[test] +fn cursor_up_down() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\n{}\n{}This should overwrite the first line{}NewText?\nNow another line{}Overwriting again{}Hello there!{}Above you!", + WORDS[0], + WORDS[1], + WORDS[2], + csi!("6A"), + csi!("10B"), + csi!("5A"), + csi!("2E"), + csi!("F"), + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_line_eq!(sb, 0, "This should overwrite the first line"); + assert_line_eq!(sb, 10, " NewText?"); + assert_line_eq!(sb, 11, "Now another line"); + assert_line_eq!(sb, 6, " Overwriting again"); + assert_line_eq!(sb, 8, "Hello there!"); + assert_line_eq!(sb, 7, "Above you!"); + assert_eq!(sb.cursor.y, 8); + assert_eq!(sb.lines.len(), 12); +} #[test] fn clear_line_from_cursor() { setup!(sb, parser); - let s = "This is the first line\nThis is the second line\nThis is the third line\n"; - let s = [ - s.as_bytes(), - &[ESC, BK, b'1', SEP, b'1', b'0', b'H'], // move cursor to (10, 1) - &[ESC, BK, b'K'], // clear line from cursor - ] - .concat(); - - let parsed = parser.feed(&s); + let s = format!( + "This is the first line\nThis is the second line\nThis is the third line\n{}{}", + csi!("1;10H"), // move cursor to (10, 1) + csi!("K") // clear line from cursor + ); + + let parsed = parser.feed(s.as_bytes()); let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); assert_eq!(sb.lines.len(), 4); assert_eq!(sb.cursor, Position::::from((10u16, 1u16))); - assert_line_eq!(sb, 1, "This is th"); + assert_line_eq!(sb, 0, "This is t"); } #[test] @@ -48,14 +121,11 @@ fn clear_from_cursor_to_top() { + "This is the third line\n" + "This is the fourth line\n" + "This is the fifth line\n"; - let s = [ - s.as_bytes(), - &[ESC, BK, b'3', SEP, b'1', b'0', b'H'], // move cursor to (10, 3) - &[ESC, BK, b'1', b'J'], // clear from cursor to beginning - ] - .concat(); - - let parsed = parser.feed(&s); + let s = format!("{s}{}{}", csi!("3;10H"), csi!("1J")); + // move cursor to (10, 3) + // clear from cursor to beginning + + let parsed = parser.feed(s.as_bytes()); let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); @@ -63,8 +133,8 @@ fn clear_from_cursor_to_top() { assert_eq!(sb.cursor, Position::::from((10u16, 3u16))); assert_line_eq!(sb, 0, ""); assert_line_eq!(sb, 1, ""); - assert_line_eq!(sb, 2, ""); - assert_line_eq!(sb, 3, " e fourth line"); + assert_line_eq!(sb, 2, " he third line"); + assert_line_eq!(sb, 3, "This is the fourth line"); assert_line_eq!(sb, 4, "This is the fifth line"); } @@ -76,23 +146,264 @@ fn clear_and_overwrite() { + "This is the third line\n" + "This is the fourth line\n" + "This is the fifth line\n"; - let s = [ - s.as_bytes(), - &[ESC, BK, b'3', SEP, b'1', b'0', b'H'], // move cursor to (10, 3) - &[ESC, BK, b'1', b'J'], // clear from cursor to beginning - b"\rNew words".as_slice(), - ] - .concat(); - - let parsed = parser.feed(&s); + let s = format!("{s}{}{}\rNew words", csi!("3;10H"), csi!("1J")); + // move cursor to (10, 3) + // clear from cursor to beginning + + let parsed = parser.feed(s.as_bytes()); let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); assert_eq!(sb.lines.len(), 6); - // assert_eq!(sb.cursor, Position::::from((10u16, 3u16))); + assert_eq!(sb.cursor, Position::::from((10u16, 3u16))); assert_line_eq!(sb, 0, ""); assert_line_eq!(sb, 1, ""); - assert_line_eq!(sb, 2, ""); - assert_line_eq!(sb, 3, "New words e fourth line"); + assert_line_eq!(sb, 2, "New wordshe third line"); + assert_line_eq!(sb, 3, "This is the fourth line"); assert_line_eq!(sb, 4, "This is the fifth line"); } + +#[test] +fn delete_chars_triple_span() { + use crossterm::style::Color; + + setup!(sb, parser, config); + let fg = Color::from(&config.appearance.fg); + drop(config); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}This is span 3{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("34m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("28P") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is span 3"); + assert_span_eq!(sb, 2, 0, expected => "This is", fg => fg); + assert_span_eq!(sb, 2, 1, expected => " span 3", fg => Color::DarkBlue); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); +} + +#[test] +fn erase_chars_triple_span() { + use crossterm::style::Color; + + setup!(sb, parser, config); + let fg = Color::from(&config.appearance.fg); + drop(config); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}This is span 3{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("34m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("28X") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is span 3"); + assert_span_eq!(sb, 2, 0, expected => "This is ", fg => fg); + assert_span_eq!(sb, 2, 1, expected => " ", fg => Color::DarkGreen); + assert_span_eq!(sb, 2, 2, expected => " span 3", fg => Color::DarkBlue); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); +} + +#[test] +fn delete_chars_double_span() { + use crossterm::style::Color; + + setup!(sb, parser, config); + { + let fg = Color::from(&config.appearance.fg); + drop(config); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("14P") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is span 2"); + assert_span_eq!(sb, 2, 0, expected => "This is", fg => fg); + assert_span_eq!(sb, 2, 1, expected => " span 2", fg => Color::DarkGreen); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); + } + sb.lines.clear(); + sb.cursor = crate::screen::Position::ORIGIN; + { + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("5P") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is 1This is span 2"); + assert_span_eq!(sb, 2, 0, expected => "This is 1"); + assert_span_eq!(sb, 2, 1, expected => "This is span 2"); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); + } +} + +#[test] +fn erase_chars_double_span() { + use crossterm::style::Color; + + setup!(sb, parser, config); + { + let fg = Color::from(&config.appearance.fg); + drop(config); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("14X") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is span 2"); + assert_span_eq!(sb, 2, 0, expected => "This is ", fg => fg); + assert_span_eq!(sb, 2, 1, expected => " span 2", fg => Color::DarkGreen); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); + } + sb.lines.clear(); + sb.cursor = crate::screen::Position::ORIGIN; + { + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\nThis is span 1{}This is span 2{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("32m"), + csi!("0m"), + csi!("F"), + csi!("7C"), + csi!("5X") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 8); + assert_eq!(sb.cursor.y, 3); + assert_line_eq!(sb, 2, "This is 1This is span 2"); + assert_span_eq!(sb, 2, 0, expected => "This is 1"); + assert_span_eq!(sb, 2, 1, expected => "This is span 2"); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); + } +} + +#[test] +fn delete_chars_single_span() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("F"), + csi!("8C"), + csi!("6P") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 9); + assert_eq!(sb.cursor.y, 2); + assert_line_eq!(sb, 1, "This is nd line of words"); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); +} + +#[test] +fn erase_chars_single_span() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}\n{}{}{}", + WORDS[0], + WORDS[1], + csi!("F"), + csi!("8C"), + csi!("6X") + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_eq!(sb.cursor.x, 9); + assert_eq!(sb.cursor.y, 2); + assert_line_eq!(sb, 1, "This is ond line of words"); + assert_eq!(sb.curr_line().unwrap().num_cells(), 80); +} + +#[test] +fn insert_delete_line() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!( + "{}\n{}{}Is this line inserted?\nCool now heres another line.\nLets add another.\nNow lets delete that last one.{}{}", + WORDS[0], + WORDS[1], + csi!("L"), + csi!("A"), + csi!("M"), + ); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_line_eq!(sb, 1, "Is this line inserted?"); + assert_line_eq!(sb, 2, "Cool now heres another line.ds"); + assert_line_eq!(sb, 3, "Now lets delete that last one."); +} + +#[test] +fn repeat_char() { + setup!(sb, parser); + let mut driver = ScreenDriver::new(&mut sb); + let seq = format!("{}\n{}{}", WORDS[0], WORDS[1], csi!("10b"),); + let parsed = parser.feed(seq.as_bytes()); + driver.process_events(parsed); + + assert_line_eq!(sb, 0, "This is one line of words"); + assert_line_eq!(sb, 1, "This is a second line of wordsssssssssss"); +} diff --git a/sericom-core/src/screen/tests/escape.rs b/sericom-core/src/screen/tests/escape.rs index df7d953..d0ceaa7 100644 --- a/sericom-core/src/screen/tests/escape.rs +++ b/sericom-core/src/screen/tests/escape.rs @@ -1,5 +1,5 @@ use super::*; -use crate::{assert_line_eq, assert_span_eq, setup}; +use crate::{assert_line_eq, assert_span_eq, csi, setup}; #[test] fn single_plain_line() { @@ -11,9 +11,7 @@ fn single_plain_line() { let bg = Color::from(&config.appearance.bg); drop(config); - // Changed pos.x == 0 because handling \n like \r\n for now - // assert_eq!(sb.cursor, Position::::from((13_u16, 1_u16))); - assert_eq!(sb.cursor, Position::::from((0_u16, 1_u16))); + assert_eq!(sb.cursor, Position::::from((1_u16, 2_u16))); // Expect one line with one span, fg=default, text padded assert_line_eq!(sb, 0, "Hello, world!"); assert_span_eq!(sb, 0, 0, fg => fg, bg => bg); @@ -31,7 +29,7 @@ fn two_lines_plain_text() { // Expected: two lines, one with "Hello" padded, one with "World" padded assert_eq!(sb.lines.len(), 3); - assert_eq!(sb.cursor, Position::::from((0_u16, 2_u16))); + assert_eq!(sb.cursor, Position::::from((1_u16, 3_u16))); assert_line_eq!(sb, 0, "Hello"); assert_line_eq!(sb, 1, "World"); assert_span_eq!(sb, 0, 0, fg => fg, bg => bg); @@ -41,22 +39,21 @@ fn two_lines_plain_text() { #[test] fn three_color_spans() { setup!(sb, parser, config); - let parsed = parser.feed(b"\x1b[31mRed\x1b[32mGreen\x1b[34mBlue\n"); + let s = format!( + "{}Red{}Green{}Blue\n", + csi!("31m"), + csi!("32m"), + csi!("34m") + ); + let parsed = parser.feed(s.as_bytes()); let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let bg = Color::from(&config.appearance.bg); drop(config); let line = sb.lines.front().unwrap(); - eprintln!( - "line num_cells: {}, num_spans: {}", - line.num_cells(), - line.len() - ); assert_eq!(sb.lines.len(), 2); - // Changed pos.x == 0 because handling \n like \r\n for now - // assert_eq!(sb.cursor, Position::::from((12_u16, 1_u16))); - assert_eq!(sb.cursor, Position::::from((0_u16, 1_u16))); + assert_eq!(sb.cursor, Position::::from((1_u16, 2_u16))); assert_eq!(line.len(), 3); // three spans assert_span_eq!(sb, 0, 0, expected => "Red", fg => Color::DarkRed, bg => bg); assert_span_eq!(sb, 0, 1, expected => "Green", fg => Color::DarkGreen, bg => bg); @@ -75,14 +72,15 @@ fn no_newline_incomplete_line() { // Should still only contain the initial empty line assert_eq!(sb.lines.len(), 1); - assert_eq!(sb.cursor, Position::::from((5_u16, 0_u16))); + assert_eq!(sb.cursor, Position::::from((6_u16, 1_u16))); assert_span_eq!(sb, 0, 0, expected => "Hello", fg => fg, bg => bg); } #[test] fn mixed_plain_and_color() { setup!(sb, parser, config); - let parsed = parser.feed(b"Normal \x1b[31mRed\n"); + let s = format!("Normal {}Red\n", csi!("31m")); + let parsed = parser.feed(s.as_bytes()); let mut driver = ScreenDriver::new(&mut sb); driver.process_events(parsed); let fg = Color::from(&config.appearance.fg); @@ -91,12 +89,9 @@ fn mixed_plain_and_color() { // Expect two spans: "Normal " default, "Red" DarkRed let line = sb.lines.front().unwrap(); - // 2 lines because of the '\n' assert_eq!(sb.lines.len(), 2); - // 2 spans assert_eq!(line.len(), 2); - // Changed pos.x == 0 because handling \n like \r\n for now - assert_eq!(sb.cursor, Position::::from((0_u16, 1_u16))); + assert_eq!(sb.cursor, Position::::from((1_u16, 2_u16))); assert_span_eq!(sb, 0, 0, expected => "Normal ", fg => fg, bg => bg); assert_span_eq!(sb, 0, 1, expected => "Red", fg => Color::DarkRed, bg => bg); } diff --git a/sericom-core/src/screen/tests/mod.rs b/sericom-core/src/screen/tests/mod.rs index 655f907..51a0c27 100644 --- a/sericom-core/src/screen/tests/mod.rs +++ b/sericom-core/src/screen/tests/mod.rs @@ -2,25 +2,59 @@ mod colors; mod components; mod cursor; mod escape; +mod parsing; +use crate::screen::process::*; pub use crate::screen::{ScreenDriver, *}; pub use crossterm::style::{Attribute, Attributes, Color}; pub use std::collections::VecDeque; -pub const TERMINAL_SIZE: (u16, u16) = (80, 24); +#[macro_export] +macro_rules! csi { + ($seq:literal) => { + concat!("\x1b\x5b", $seq) + }; +} + +#[macro_export] +macro_rules! esc { + ($seq:literal) => { + concat!("\x1b", $seq) + }; +} + +#[macro_export] +macro_rules! assert_event { + ($lhs:expr, $text:literal) => { + assert_eq!($lhs, ParserEvent::Text($text)) + }; + ($lhs:expr, c0 = $c0:path) => { + assert_eq!($lhs, ParserEvent::C0($c0)) + }; + ($lhs:expr, c1 = $c1:path) => { + assert_eq!($lhs, ParserEvent::C1($c1)) + }; + ($lhs:expr, $kind:path, $params:expr) => { + assert_eq!( + $lhs, + ParserEvent::CSI(CSI { + kind: $kind, + params: $params + }) + ) + }; +} #[macro_export] macro_rules! setup { ($sb:ident, $parser:ident) => { $crate::configs::init_for_tests(); - let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); - let mut $sb = ScreenBuffer::new(rect); + let mut $sb = ScreenBuffer::new((80u16, 24u16).into()); let mut $parser = ByteParser::new(); }; ($sb:ident, $parser:ident, $config:ident) => { $crate::configs::init_for_tests(); - let rect = Rect::new(Position::ORIGIN, TERMINAL_SIZE.0, TERMINAL_SIZE.1); - let mut $sb = ScreenBuffer::new(rect); + let mut $sb = ScreenBuffer::new((80u16, 24u16).into()); let mut $parser = ByteParser::new(); let $config = $crate::configs::get_config().unwrap(); }; diff --git a/sericom-core/src/screen/tests/parsing.rs b/sericom-core/src/screen/tests/parsing.rs new file mode 100644 index 0000000..658de8a --- /dev/null +++ b/sericom-core/src/screen/tests/parsing.rs @@ -0,0 +1,31 @@ +use super::*; +use crate::assert_event; + +#[test] +fn parser_basic() { + let mut parser = ByteParser::new(); + let bytes = b"\x1b[HI should be home now at 1,1\n\ + This is now the second linr, oops lets change that\x1b[24Gline\x1b[E\ + This should now be the third line. Lets do the next in blue and bold.\n\ + \x1b[1;34mAm I blue now??\x1b[0m\ + "; + let parsed = parser.feed(bytes); + assert_event!(parsed[0], CsiKind::PositionCUP, &[]); + assert_event!(parsed[1], b"I should be home now at 1,1"); + assert_event!(parsed[2], c0 = C0::NL); + assert_event!( + parsed[3], + b"This is now the second linr, oops lets change that" + ); + assert_event!(parsed[4], CsiKind::CharAbsCHA, b"24"); + assert_event!(parsed[5], b"line"); + assert_event!(parsed[6], CsiKind::NextLine, &[]); + assert_event!( + parsed[7], + b"This should now be the third line. Lets do the next in blue and bold." + ); + assert_event!(parsed[8], c0 = C0::NL); + assert_event!(parsed[9], CsiKind::SGR, b"1;34"); + assert_event!(parsed[10], b"Am I blue now??"); + assert_event!(parsed[11], CsiKind::SGR, b"0"); +} diff --git a/sericom-core/src/serial_actor/mod.rs b/sericom-core/src/serial_actor/mod.rs index 893f015..95a3adf 100644 --- a/sericom-core/src/serial_actor/mod.rs +++ b/sericom-core/src/serial_actor/mod.rs @@ -3,8 +3,6 @@ pub mod tasks; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; - use crate::SeriError; /// Represents messages/commands that are sent from worker tasks to the [`SerialActor`] to process. @@ -46,24 +44,18 @@ pub enum SerialEvent { /// It broadcasts [`SerialEvent`]s to worker tasks via a [`tokio::sync::broadcast`] /// channel, and receives [`SerialMessage`]s from worker tasks via a [`tokio::sync::mpsc`] /// channel. -pub struct SerialActor { - connection: S, - // connection: serial2_tokio::SerialPort, +pub struct SerialActor { + connection: serial2_tokio::SerialPort, command_rx: tokio::sync::mpsc::Receiver, tasks_broadcast: tokio::sync::broadcast::Sender, } -impl SerialActor -where - S: AsyncRead + AsyncWrite + AsyncWriteExt + Unpin + Send + 'static, -{ +impl SerialActor { /// Constructs a [`SerialActor`] Takes a serial port connection, /// receiver to a command channel, and a sender to a broadcast channel. #[must_use] pub const fn new( - // pub const fn new( - // connection: serial2_tokio::SerialPort, - connection: S, + connection: serial2_tokio::SerialPort, command_rx: tokio::sync::mpsc::Receiver, tasks_broadcast: tokio::sync::broadcast::Sender, ) -> Self { @@ -138,9 +130,7 @@ where } } } -} -impl SerialActor { async fn send_break(&self) { use tokio::time::{Duration, sleep}; let _ = self.connection.set_break(true); diff --git a/sericom-core/src/session/handle.rs b/sericom-core/src/session/handle.rs index 43dab8e..64f5d1a 100644 --- a/sericom-core/src/session/handle.rs +++ b/sericom-core/src/session/handle.rs @@ -66,82 +66,6 @@ where res } -impl SessionHandle { - pub fn spawn_with_stream( - meta: &super::SessionMeta, - with_file: Option>, - headless: bool, - mgr_tx: oneshot::Sender, - stream: S, - ) -> miette::Result - where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + AsyncWriteExt + Unpin + Send + 'static, - { - let (tx, rx) = tokio::sync::mpsc::channel::(100); - let (events_tx, _) = tokio::sync::broadcast::channel::(128); - - let (term_w, term_h) = if headless { - #[cfg(not(test))] - { - (120, 24) - } - #[cfg(test)] - { - (120, 10) - } - } else { - crossterm::terminal::size().unwrap_or((80, 24)) - }; - - let sb = ScreenBuffer::new(Rect::new((0u16, 0u16).into(), term_w, term_h)); - let buffer = Arc::new(tokio::sync::RwLock::new(sb)); - let actor = SerialActor::new(stream, rx, events_tx.clone()); - - let span = { - let info = tracing::info_span!("session"); - let dbg = tracing::debug_span!("session", port = %meta.port.display()); - if dbg.is_disabled() { info } else { dbg } - }; - let _enter = span.enter(); - debug!(port=%meta.port.display(), baud=%meta.baud, "opened connection"); - - let mut handle = Self { - buffer, - tx, - events_tx, - tasks: JoinSet::new(), - }; - handle.spawn_monitor_task(mgr_tx); - handle.spawn_actor_task(actor); - handle.spawn_parse_task(); - - if with_file.is_some() { - let config = get_config()?; - let default_out_dir = PathBuf::from(&config.defaults.out_dir); - let file_path = if let Some(Some(path)) = with_file { - // If given an absolute path - override the `default_out_dir` - if path.is_absolute() { - let parent = path.parent().unwrap_or(&default_out_dir); - create_recursive!(parent); - path - } else { - let joined_path = default_out_dir.join(&path); - let parent_path = joined_path.parent().expect("Does not have root"); - create_recursive!(parent_path); - joined_path - } - } else { - let default_out_dir = PathBuf::from(&config.defaults.out_dir); - drop(config); - compat_port_path!(default_out_dir, &meta.port) - }; - handle.spawn_file_task(file_path); - } - - Ok(handle) - } -} - impl SessionHandle { /// Spawn a new session and optionally stream to a `file` or run in headless mode. pub fn spawn( @@ -261,10 +185,7 @@ impl SessionHandle { } /// Spawns a task that runs the [`SerialActor`] for the session. - fn spawn_actor_task(&mut self, actor: SerialActor) - where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + AsyncWriteExt + Unpin + Send + 'static, - { + fn spawn_actor_task(&mut self, actor: SerialActor) { self.tasks.spawn(instrument_task( actor.run(), Some(Span::current()), diff --git a/sericom-core/src/session/mod.rs b/sericom-core/src/session/mod.rs index b46af6d..b5afede 100644 --- a/sericom-core/src/session/mod.rs +++ b/sericom-core/src/session/mod.rs @@ -115,43 +115,6 @@ impl SessionManager { Ok(id as SessionID) } - #[allow(clippy::cast_possible_truncation)] - pub fn spawn_with_stream( - &mut self, - port: std::path::PathBuf, - baud: u32, - f_path: Option>, - headless: bool, - stream: S, - ) -> miette::Result - where - S: tokio::io::AsyncRead - + tokio::io::AsyncWrite - + tokio::io::AsyncWriteExt - + Unpin - + Send - + 'static, - { - let id = self.metas.len(); - - if id >= u8::MAX as usize { - warn!(%id, "max sessions reached"); - return Err(miette::miette!("Max sessions reached"))?; - } - - let (err_tx, err_rx) = oneshot::channel::(); - let meta = SessionMeta { baud, port }; - let handle = SessionHandle::spawn_with_stream(&meta, f_path, headless, err_tx, stream)?; - info!(%id, port=%meta.port.display(), %baud, "created session"); - - self.handles.push(handle); - self.metas.push(meta); - self.start_monitor(id as SessionID, err_rx); - - // Cast is fine, verified that id is < u8::MAX - Ok(id as SessionID) - } - /// Kill/shutdown the session for [`SessionID`] #[allow(clippy::cast_possible_truncation)] pub async fn kill(&mut self, id: SessionID) {