From 65cea975e36094400f2512977966792ff4ed322a Mon Sep 17 00:00:00 2001 From: "Matheus T. dos Santos" Date: Fri, 16 Feb 2024 13:04:04 -0300 Subject: [PATCH] feat: add rich text to handle invisible character and ANSI color codes --- src/main.rs | 1 + src/messages.rs | 61 +++++--- src/plugin_manager.rs | 2 +- src/rich_string.rs | 343 ++++++++++++++++++++++++++++++++++++++++++ src/serial.rs | 4 +- src/text.rs | 193 +++--------------------- 6 files changed, 408 insertions(+), 196 deletions(-) create mode 100644 src/rich_string.rs diff --git a/src/main.rs b/src/main.rs index ebed7b3..45e5f5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod messages; mod plugin; mod plugin_installer; mod plugin_manager; +mod rich_string; mod serial; mod text; diff --git a/src/messages.rs b/src/messages.rs index 81eb183..7d6be1f 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -1,3 +1,4 @@ +use crate::rich_string::RichText; use crate::text::ViewData; use chrono::{DateTime, Local}; use tui::style::Color; @@ -24,7 +25,7 @@ pub enum UserTxData { pub enum SerialRxData { RxData { timestamp: DateTime, - content: String, + content: Vec, }, TxData { timestamp: DateTime, @@ -67,7 +68,10 @@ impl Into for SerialRxData { fn into(self) -> ViewData { match self { SerialRxData::RxData { timestamp, content } => { - ViewData::new(timestamp, content, Color::Reset, Color::Reset) + RichText::new(content, Color::Reset, Color::Reset) + .decode_ansi_color() + .highlight_invisible() + .into_view_data(timestamp) } SerialRxData::TxData { timestamp, @@ -75,14 +79,17 @@ impl Into for SerialRxData { is_successful, } => { if is_successful { - ViewData::new(timestamp, content, Color::Black, Color::LightCyan) + RichText::from_string(content, Color::Black, Color::LightCyan) + .highlight_invisible() + .into_view_data(timestamp) } else { - ViewData::new( - timestamp, + RichText::from_string( format!("Cannot send \"{}\"", content), Color::White, Color::LightRed, ) + .highlight_invisible() + .into_view_data(timestamp) } } SerialRxData::Command { @@ -92,19 +99,21 @@ impl Into for SerialRxData { is_successful, } => { if is_successful { - ViewData::new( - timestamp, + RichText::from_string( format!(" {}", command_name, content), Color::Black, Color::LightGreen, ) + .highlight_invisible() + .into_view_data(timestamp) } else { - ViewData::new( - timestamp, + RichText::from_string( format!("Cannot send ", command_name), Color::White, Color::LightRed, ) + .highlight_invisible() + .into_view_data(timestamp) } } SerialRxData::HexString { @@ -113,19 +122,17 @@ impl Into for SerialRxData { is_successful, } => { if is_successful { - ViewData::new( - timestamp, - format!("{:02x?}", &content), - Color::Black, - Color::Yellow, - ) + RichText::from_string(format!("{:02x?}", &content), Color::Black, Color::Yellow) + .highlight_invisible() + .into_view_data(timestamp) } else { - ViewData::new( - timestamp, + RichText::from_string( format!("Cannot send {:02x?}", &content), Color::White, Color::LightRed, ) + .highlight_invisible() + .into_view_data(timestamp) } } SerialRxData::Plugin { @@ -135,19 +142,21 @@ impl Into for SerialRxData { is_successful, } => { if is_successful { - ViewData::new( - timestamp, + RichText::from_string( format!(" [{plugin_name}] {content} "), Color::Black, Color::White, ) + .highlight_invisible() + .into_view_data(timestamp) } else { - ViewData::new( - timestamp, + RichText::from_string( format!(" [{plugin_name}] {content} "), Color::White, Color::Red, ) + .highlight_invisible() + .into_view_data(timestamp) } } SerialRxData::PluginSerialTx { @@ -157,19 +166,21 @@ impl Into for SerialRxData { is_successful, } => { if is_successful { - ViewData::new( - timestamp, + RichText::from_string( format!(" [{plugin_name}] => {:02x?} ", content), Color::Black, Color::White, ) + .highlight_invisible() + .into_view_data(timestamp) } else { - ViewData::new( - timestamp, + RichText::from_string( format!(" [{plugin_name}] => Fail to send {:02x?} ", content), Color::White, Color::Red, ) + .highlight_invisible() + .into_view_data(timestamp) } } } diff --git a/src/plugin_manager.rs b/src/plugin_manager.rs index 2d1993c..3fce1c6 100644 --- a/src/plugin_manager.rs +++ b/src/plugin_manager.rs @@ -164,7 +164,7 @@ impl PluginManager { continue; }; - let serial_rx_call = plugin.serial_rx_call(line.as_bytes().to_vec()); + let serial_rx_call = plugin.serial_rx_call(line.clone()); let plugin_name = plugin.name().to_string(); self.serial_rx_tx .send((plugin_name, serial_rx_call)) diff --git a/src/rich_string.rs b/src/rich_string.rs new file mode 100644 index 0000000..3f3ad46 --- /dev/null +++ b/src/rich_string.rs @@ -0,0 +1,343 @@ +use crate::text::ViewData; +use chrono::{DateTime, Local}; +use std::collections::HashMap; +use tui::style::{Color, Style}; +use tui::text::Span; + +pub struct RichText { + content: Vec, + fg: Color, + bg: Color, +} + +impl RichText { + pub fn new(content: Vec, fg: Color, bg: Color) -> Self { + Self { content, fg, bg } + } + + pub fn from_string(content: String, fg: Color, bg: Color) -> Self { + Self { + content: content.as_bytes().to_vec(), + fg, + bg, + } + } + + pub fn decode_ansi_color(self) -> RichTextAnsi { + RichTextAnsi::new(self) + } + + pub fn highlight_invisible(self) -> RichTextWithInvisible { + RichTextWithInvisible::new(self) + } + + pub fn to_span<'a>(&self) -> Span<'a> { + Span::styled( + String::from_utf8_lossy(&self.content).to_string(), + Style::default().bg(self.bg).fg(self.fg), + ) + } + + pub fn crop_prefix_len(&self, len: usize) -> Self { + Self { + content: if len >= self.content.len() { + vec![] + } else { + self.content[len..].to_vec() + }, + fg: self.fg, + bg: self.bg, + } + } +} + +pub struct RichTextWithInvisible { + rich_texts: Vec, +} + +impl RichTextWithInvisible { + pub fn into_view_data(self, timestamp: DateTime) -> ViewData { + ViewData::new(timestamp, self.rich_texts) + } + + fn new(rich_text: RichText) -> Self { + if rich_text.content.is_empty() { + return Self { rich_texts: vec![] }; + } + + enum State { + None, + Visible, + Invisible, + } + + let (fg, bg) = (rich_text.fg, rich_text.bg); + let (hl_fg, hl_bg) = Self::get_colors(rich_text.fg, rich_text.fg, true); + + let (buffer, state, acc) = rich_text.content.into_iter().fold( + (vec![], State::None, vec![]), + |(buffer, state, acc), byte| match state { + State::None => ( + vec![byte], + Self::is_visible(byte) + .then_some(State::Visible) + .unwrap_or(State::Invisible), + acc, + ), + State::Visible => { + if Self::is_visible(byte) { + ( + buffer.into_iter().chain([byte]).collect(), + State::Visible, + acc, + ) + } else { + ( + vec![], + State::Invisible, + acc.into_iter() + .chain([RichText::new(buffer, rich_text.fg, rich_text.bg)]) + .collect(), + ) + } + } + State::Invisible => { + if Self::is_visible(byte) { + ( + vec![], + State::Visible, + acc.into_iter() + .chain([RichText::new(Self::bytes_to_rich(buffer), hl_fg, hl_bg)]) + .collect(), + ) + } else { + ( + buffer.into_iter().chain([byte]).collect(), + State::Invisible, + acc, + ) + } + } + }, + ); + + Self { + rich_texts: if buffer.is_empty() { + acc + } else { + let (fg, bg) = Self::get_colors(fg, bg, matches!(state, State::Invisible)); + let buffer = if matches!(state, State::Invisible) { + Self::bytes_to_rich(buffer) + } else { + buffer + }; + + acc.into_iter() + .chain([RichText::new(buffer, fg, bg)]) + .collect() + }, + } + } + + fn is_visible(byte: u8) -> bool { + 0x20 <= byte && byte <= 0x7E + } + + fn bytes_to_rich(bytes: Vec) -> Vec { + bytes + .into_iter() + .flat_map(|b| match b { + b'\n' => b"\\n".to_vec(), + b'\r' => b"\\r".to_vec(), + b'\0' => b"\\0".to_vec(), + x => format!("\\x{:02x}", x).as_bytes().to_vec(), + }) + .collect() + } + + fn get_colors(fg: Color, bg: Color, is_highlight: bool) -> (Color, Color) { + if !is_highlight { + return (fg, bg); + } + + if bg == Color::Magenta + || bg == Color::LightMagenta + || fg == Color::Magenta + || fg == Color::LightMagenta + { + (Color::LightYellow, bg) + } else { + (Color::LightMagenta, bg) + } + } +} + +pub struct RichTextAnsi { + rich_texts: Vec, +} + +impl RichTextAnsi { + pub fn highlight_invisible(self) -> RichTextWithInvisible { + let rich_texts = self + .rich_texts + .into_iter() + .flat_map(|rich_text| RichTextWithInvisible::new(rich_text).rich_texts) + .collect::>(); + + RichTextWithInvisible { rich_texts } + } + + fn new(rich_text: RichText) -> Self { + if rich_text.content.is_empty() { + return RichTextAnsi { rich_texts: vec![] }; + } + + enum State { + None, + Escape, + Normal, + } + + let (fg, bg) = (rich_text.fg, rich_text.bg); + let (text_buffer, _color_pattern, state, acc, current_fg, current_bg) = + rich_text.content.into_iter().fold( + (vec![], vec![], State::None, Vec::::new(), fg, bg), + |(text_buffer, color_pattern, state, acc, current_fg, current_bg), byte| match state + { + State::None => { + if byte == 0x1B { + ( + vec![], + vec![byte], + State::Escape, + acc, + current_fg, + current_bg, + ) + } else { + ( + vec![byte], + vec![], + State::Normal, + acc, + current_fg, + current_bg, + ) + } + } + State::Escape => { + let color_pattern_ext = + color_pattern.into_iter().chain([byte]).collect::>(); + match RichTextAnsi::match_color_pattern(&color_pattern_ext) { + Ok(Some((new_fg, new_bg))) => ( + vec![], + vec![], + State::Normal, + acc.into_iter() + .chain([RichText { + content: text_buffer, + fg: current_fg, + bg: current_bg, + }]) + .collect::>(), + new_fg, + new_bg, + ), + Ok(None) => ( + text_buffer, + color_pattern_ext, + State::Escape, + acc, + current_fg, + current_bg, + ), + Err(_) => ( + text_buffer + .into_iter() + .chain(color_pattern_ext) + .collect::>(), + vec![], + State::Normal, + acc, + current_fg, + current_bg, + ), + } + } + State::Normal => { + if byte == 0x1B { + ( + text_buffer, + vec![byte], + State::Escape, + acc, + current_fg, + current_bg, + ) + } else { + ( + text_buffer.into_iter().chain([byte]).collect(), + vec![], + State::Normal, + acc, + current_fg, + current_bg, + ) + } + } + }, + ); + + Self { + rich_texts: if text_buffer.is_empty() { + acc + } else { + acc.into_iter() + .chain([match state { + State::None => unreachable!(), + State::Normal | State::Escape => RichText { + content: text_buffer, + fg: current_fg, + bg: current_bg, + }, + }]) + .collect() + }, + } + } + + fn match_color_pattern(pattern: &[u8]) -> Result, ()> { + let pattern_lut = HashMap::new() + .into_iter() + .chain([ + (b"\x1B[0m".to_vec(), (Color::Reset, Color::Reset)), + (b"\x1B[30m".to_vec(), (Color::Black, Color::Reset)), + (b"\x1B[0;30m".to_vec(), (Color::Black, Color::Reset)), + (b"\x1B[31m".to_vec(), (Color::Red, Color::Reset)), + (b"\x1B[0;31m".to_vec(), (Color::Red, Color::Reset)), + (b"\x1B[32m".to_vec(), (Color::Green, Color::Reset)), + (b"\x1B[0;32m".to_vec(), (Color::Green, Color::Reset)), + (b"\x1B[33m".to_vec(), (Color::Yellow, Color::Reset)), + (b"\x1B[0;33m".to_vec(), (Color::Yellow, Color::Reset)), + (b"\x1B[34m".to_vec(), (Color::Blue, Color::Reset)), + (b"\x1B[0;34m".to_vec(), (Color::Blue, Color::Reset)), + (b"\x1B[35m".to_vec(), (Color::Magenta, Color::Reset)), + (b"\x1B[0;35m".to_vec(), (Color::Magenta, Color::Reset)), + (b"\x1B[36m".to_vec(), (Color::Cyan, Color::Reset)), + (b"\x1B[0;36m".to_vec(), (Color::Cyan, Color::Reset)), + (b"\x1B[37m".to_vec(), (Color::Gray, Color::Reset)), + (b"\x1B[0;37m".to_vec(), (Color::Gray, Color::Reset)), + ]) + .collect::, (Color, Color)>>(); + + if let Some((fg, bg)) = pattern_lut.get(pattern) { + return Ok(Some((*fg, *bg))); + } + + if pattern_lut.keys().any(|x| x.starts_with(pattern)) { + return Ok(None); + } + + Err(()) + } +} diff --git a/src/serial.rs b/src/serial.rs index 09b04a6..626713a 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -109,7 +109,7 @@ impl SerialIF { is_connected.clone(), ); - let mut line = String::new(); + let mut line = vec![]; let mut buffer = [0u8]; let mut now = Instant::now(); @@ -206,7 +206,7 @@ impl SerialIF { match serial.read(&mut buffer) { Ok(_) => { - line.push(buffer[0] as char); + line.push(buffer[0]); if buffer[0] == b'\n' { data_tx .send(SerialRxData::RxData { diff --git a/src/text.rs b/src/text.rs index 9105607..6243db6 100644 --- a/src/text.rs +++ b/src/text.rs @@ -1,4 +1,5 @@ use crate::messages::SerialRxData; +use crate::rich_string::RichText; use chrono::{DateTime, Local}; use std::marker::PhantomData; use tui::backend::Backend; @@ -40,135 +41,6 @@ impl TextView { } } - fn is_visible(x: char) -> bool { - 0x20 <= x as u8 && x as u8 <= 0x7E - } - - fn print_invisible(data: String) -> String { - data.chars() - .map(|x| match x { - x if TextView::::is_visible(x) => x.to_string(), - '\0' => "\\0".to_string(), - '\n' => "\\n".to_string(), - '\r' => "\\r".to_string(), - _ => format!("\\x{:02x}", x as u8), - }) - .collect::>() - .join("") - } - - fn highlight_invisible(&self, in_text: &str, color: Color) -> Vec<(String, Color)> { - #[derive(PartialEq)] - enum Mode { - Visible, - Invisible, - } - - let highlight_color = if color == Color::LightMagenta { - Color::LightCyan - } else { - Color::LightMagenta - }; - let mut output = vec![]; - let mut text = "".to_string(); - let mut highlight_text = "".to_string(); - let mut mode = Mode::Visible; - - for ch in in_text.chars() { - if TextView::::is_visible(ch) { - text.push(ch); - if mode == Mode::Invisible { - output.push(( - TextView::::print_invisible(highlight_text.clone()), - highlight_color, - )); - highlight_text.clear(); - } - mode = Mode::Visible; - } else { - highlight_text.push(ch); - if mode == Mode::Visible { - output.push((text.clone(), color)); - text.clear(); - } - mode = Mode::Invisible; - } - } - - if !text.is_empty() { - output.push((text.clone(), color)); - } else if !highlight_text.is_empty() { - output.push(( - TextView::::print_invisible(highlight_text.clone()), - highlight_color, - )); - } - - output - } - - fn decode_ansi_color(&self, text: &str, color: Color) -> Vec<(String, Color)> { - if text.is_empty() { - return vec![]; - } - - let splitted = text.split("\x1B[").collect::>(); - let mut res = vec![]; - - let pattern_n_color = [ - ("0m", Color::Reset), - ("30m", Color::Black), - ("0;30m", Color::Black), - ("31m", Color::Red), - ("0;31m", Color::Red), - ("32m", Color::Green), - ("0;32m", Color::Green), - ("33m", Color::Yellow), - ("0;33m", Color::Yellow), - ("34m", Color::Blue), - ("0;34m", Color::Blue), - ("35m", Color::Magenta), - ("0;35m", Color::Magenta), - ("36m", Color::Cyan), - ("0;36m", Color::Cyan), - ("37m", Color::Gray), - ("0;37m", Color::Gray), - ]; - - for splitted_str in splitted.iter() { - if splitted_str.is_empty() { - continue; - } - - if pattern_n_color.iter().all(|(pattern, color)| { - if splitted_str.starts_with(pattern) { - let final_str = splitted_str - .to_string() - .replace(pattern, "") - .trim() - .to_string(); - if final_str.is_empty() { - return true; - } - - res.push((final_str, *color)); - return false; - } - - true - }) { - let fmt_splitted_str = if splitted_str.starts_with("0m") { - splitted_str.replace("0m", "") - } else { - splitted_str.to_string() - }; - res.push((fmt_splitted_str, color)); - } - } - - res - } - pub fn draw(&self, f: &mut Frame, rect: Rect) { let scroll = if self.auto_scroll { (self.max_main_axis(), self.scroll.1) @@ -200,34 +72,27 @@ impl TextView { ) }; - let text = - coll.iter() - .map(|x| { - let scroll = scroll.1 as usize; - let content = if scroll >= x.data.len() { - "" - } else { - &x.data[scroll..] - }; - - let texts_colors = self.decode_ansi_color(content, x.fg); - let texts_colors = texts_colors - .into_iter() - .flat_map(|(text, color)| self.highlight_invisible(&text, color)) - .collect::>(); - let timestamp_span = Span::styled( - format!("{} ", x.timestamp.format("%H:%M:%S.%3f")), - Style::default().fg(Color::DarkGray), - ); - let mut content = vec![timestamp_span]; - - content.extend(texts_colors.into_iter().map(|(text, color)| { - Span::styled(text, Style::default().bg(x.bg).fg(color)) - })); - - Spans::from(content) - }) - .collect::>(); + let text = coll + .iter() + .map(|ViewData { data, timestamp }| { + let timestamp_span = Span::styled( + format!("{} ", timestamp.format("%H:%M:%S.%3f")), + Style::default().fg(Color::DarkGray), + ); + let content = vec![timestamp_span] + .into_iter() + .chain(data.iter().enumerate().map(|(i, rich_text)| { + if i == 0 { + rich_text.crop_prefix_len(scroll.1 as usize).to_span() + } else { + rich_text.to_span() + } + })) + .collect::>(); + + Spans::from(content) + }) + .collect::>(); let paragraph = Paragraph::new(text).block(block); f.render_widget(paragraph, rect); } @@ -294,21 +159,13 @@ impl TextView { } } -#[derive(Clone)] pub struct ViewData { timestamp: DateTime, - data: String, - fg: Color, - bg: Color, + data: Vec, } impl ViewData { - pub fn new(timestamp: DateTime, data: String, fg: Color, bg: Color) -> Self { - Self { - timestamp, - data, - fg, - bg, - } + pub fn new(timestamp: DateTime, data: Vec) -> Self { + Self { timestamp, data } } }