diff --git a/Cargo.lock b/Cargo.lock index f28362f..2ff24be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3325,6 +3325,7 @@ dependencies = [ name = "pipeview-tui" version = "0.1.0" dependencies = [ + "ansitok", "clap", "crossterm 0.28.1", "hex", diff --git a/crates/pipeview-tui/Cargo.toml b/crates/pipeview-tui/Cargo.toml index 5ac8c7e..acf9c6f 100644 --- a/crates/pipeview-tui/Cargo.toml +++ b/crates/pipeview-tui/Cargo.toml @@ -22,3 +22,4 @@ image = { workspace = true } mlua = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +ansitok = "0.3" diff --git a/crates/pipeview-tui/src/ansi.rs b/crates/pipeview-tui/src/ansi.rs new file mode 100644 index 0000000..e32dbd3 --- /dev/null +++ b/crates/pipeview-tui/src/ansi.rs @@ -0,0 +1,295 @@ +use ansitok::{AnsiColor, ElementKind, VisualAttribute, parse_ansi, parse_ansi_sgr}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; + +/// Parse ANSI-encoded text and return a Vec of styled Spans. +/// +/// ANSI SGR sequences (colors, bold, italic, underline, strikethrough) +/// are converted to ratatui `Style` applied on top of `base_style`. +/// Non-SGR sequences (cursor movement, etc.) are silently ignored. +pub fn ansi_to_spans(text: &str, base_style: Style) -> Vec> { + let mut spans = Vec::new(); + let mut style = AnsiStyleStack::default(); + + for element in parse_ansi(text) { + match element.kind() { + ElementKind::Text => { + let slice = &text[element.start()..element.end()]; + if !slice.is_empty() { + spans.push(Span::styled( + slice.to_string(), + style.merge(base_style), + )); + } + } + ElementKind::Sgr => { + let sgr = &text[element.start()..element.end()]; + apply_sgr_sequence(&mut style, sgr); + } + _ => {} + } + } + + spans +} + +/// Extract visible text from ANSI-encoded string (strips escape sequences). +/// +/// This is the text that a user would actually see on a terminal — +/// useful for search, copy, and line counting. +pub fn ansi_visible_text(text: &str) -> String { + let mut visible = String::new(); + for element in parse_ansi(text) { + if element.kind() == ElementKind::Text { + visible.push_str(&text[element.start()..element.end()]); + } + } + visible +} + +// ── internal style stack ── + +#[derive(Default, Clone)] +struct AnsiStyleStack { + fg: Option, + bg: Option, + bold: bool, + italic: bool, + underline: bool, + strikethrough: bool, +} + +impl AnsiStyleStack { + fn apply_sgr_attr(&mut self, attr: VisualAttribute) { + match attr { + VisualAttribute::Reset(0) => *self = Self::default(), + VisualAttribute::Reset(22) => self.bold = false, + VisualAttribute::Reset(23) => self.italic = false, + VisualAttribute::Reset(24) => self.underline = false, + VisualAttribute::Reset(29) => self.strikethrough = false, + VisualAttribute::Reset(39) => self.fg = None, + VisualAttribute::Reset(49) => self.bg = None, + VisualAttribute::Reset(_) => {} + VisualAttribute::Bold => self.bold = true, + VisualAttribute::Faint => self.bold = false, + VisualAttribute::Italic => self.italic = true, + VisualAttribute::Underline => self.underline = true, + VisualAttribute::Crossedout => self.strikethrough = true, + VisualAttribute::FgColor(c) => self.fg = Some(ansi_color_to_ratatui(c)), + VisualAttribute::BgColor(c) => self.bg = Some(ansi_color_to_ratatui(c)), + _ => {} + } + } + + fn apply_sgr(&mut self, sgr: &str) { + // Strip ESC[ ... m wrapper and delegate to ansitok's SGR parser + let params = sgr + .strip_prefix("\x1b[") + .and_then(|s| s.strip_suffix('m')) + .unwrap_or(sgr); + + if params.is_empty() { + *self = Self::default(); + return; + } + + for output in parse_ansi_sgr(sgr) { + if let Some(attr) = output.as_escape() { + self.apply_sgr_attr(attr); + } + } + } + + fn merge(&self, base: Style) -> Style { + let mut style = base; + + if let Some(fg) = self.fg { + if self.bold { + style = style.fg(brighten(fg)); + } else { + style = style.fg(fg); + } + } + if let Some(bg) = self.bg { + style = style.bg(bg); + } + if self.italic { + style = style.add_modifier(Modifier::ITALIC); + } + if self.underline { + style = style.add_modifier(Modifier::UNDERLINED); + } + if self.strikethrough { + style = style.add_modifier(Modifier::CROSSED_OUT); + } + if self.bold { + // Bold applied only when there's no explicit fg (brighten handles it otherwise) + style = style.add_modifier(Modifier::BOLD); + } + + style + } +} + +fn apply_sgr_sequence(style: &mut AnsiStyleStack, sgr: &str) { + style.apply_sgr(sgr); +} + +// ── color conversion ── + +fn ansi_color_to_ratatui(color: AnsiColor) -> Color { + match color { + AnsiColor::Bit4(c) => ansi_4bit(c), + AnsiColor::Bit8(c) => ansi_256(c), + AnsiColor::Bit24 { r, g, b } => Color::Rgb(r, g, b), + } +} + +fn ansi_4bit(code: u8) -> Color { + match code { + 30 => Color::Rgb(0, 0, 0), + 31 => Color::Rgb(194, 54, 33), + 32 => Color::Rgb(37, 188, 36), + 33 => Color::Rgb(173, 173, 39), + 34 => Color::Rgb(73, 46, 225), + 35 => Color::Rgb(211, 56, 211), + 36 => Color::Rgb(51, 187, 200), + 37 => Color::Rgb(203, 204, 205), + + 90 => Color::Rgb(129, 131, 131), + 91 => Color::Rgb(252, 57, 31), + 92 => Color::Rgb(49, 231, 34), + 93 => Color::Rgb(234, 236, 35), + 94 => Color::Rgb(88, 51, 255), + 95 => Color::Rgb(249, 53, 248), + 96 => Color::Rgb(20, 240, 240), + 97 => Color::Rgb(233, 235, 235), + + // Background colors (same values as foreground but offset by 10) + 40 => Color::Rgb(0, 0, 0), + 41 => Color::Rgb(194, 54, 33), + 42 => Color::Rgb(37, 188, 36), + 43 => Color::Rgb(173, 173, 39), + 44 => Color::Rgb(73, 46, 225), + 45 => Color::Rgb(211, 56, 211), + 46 => Color::Rgb(51, 187, 200), + 47 => Color::Rgb(203, 204, 205), + + 100 => Color::Rgb(129, 131, 131), + 101 => Color::Rgb(252, 57, 31), + 102 => Color::Rgb(49, 231, 34), + 103 => Color::Rgb(234, 236, 35), + 104 => Color::Rgb(88, 51, 255), + 105 => Color::Rgb(249, 53, 248), + 106 => Color::Rgb(20, 240, 240), + 107 => Color::Rgb(233, 235, 235), + + _ => Color::Rgb(255, 255, 255), + } +} + +fn ansi_256(code: u8) -> Color { + match code { + 0..=15 => ansi_4bit(if code < 8 { code + 30 } else { code + 82 }), + 16..=231 => { + let idx = code - 16; + let cube = [0, 95, 135, 175, 215, 255]; + let r = cube[(idx / 36) as usize]; + let g = cube[((idx / 6) % 6) as usize]; + let b = cube[(idx % 6) as usize]; + Color::Rgb(r, g, b) + } + 232..=255 => { + let v = (code - 232) * 10 + 8; + Color::Rgb(v, v, v) + } + } +} + +fn brighten(color: Color) -> Color { + match color { + Color::Rgb(r, g, b) => Color::Rgb( + ((r as u32) * 13 / 10).min(255) as u8, + ((g as u32) * 13 / 10).min(255) as u8, + ((b as u32) * 13 / 10).min(255) as u8, + ), + _ => color, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Style; + + fn visible<'a>(spans: &[Span<'a>]) -> String { + spans.iter().map(|s| s.content.as_ref()).collect::() + } + + #[test] + fn plain_text_no_ansi() { + let spans = ansi_to_spans("hello", Style::default()); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].content, "hello"); + } + + #[test] + fn fg_red_reset() { + let spans = ansi_to_spans("\x1b[31mred\x1b[0m plain", Style::default()); + assert!(spans.len() >= 2); + assert_eq!(visible(&spans), "red plain"); + } + + #[test] + fn empty_sgr_resets_all() { + let base = Style::default().fg(Color::Rgb(1, 2, 3)); + let spans = ansi_to_spans("\x1b[31mred\x1b[m plain", base); + assert_eq!(visible(&spans), "red plain"); + assert!(spans.len() >= 2); + // First span should have red foreground + assert_ne!(spans[0].style.fg, Some(Color::Rgb(1, 2, 3))); + // Last span should have base foreground + assert_eq!(spans.last().unwrap().style.fg, Some(Color::Rgb(1, 2, 3))); + } + + #[test] + fn bold_brightens_color() { + let spans = ansi_to_spans("\x1b[1;31mbold red\x1b[0m", Style::default()); + assert_eq!(spans.len(), 1); + assert_eq!(visible(&spans), "bold red"); + } + + #[test] + fn strip_ansi_codes_from_output() { + let spans = ansi_to_spans("\x1b[32mgreen\x1b[0m", Style::default()); + assert_eq!(visible(&spans), "green"); + } + + #[test] + fn visible_text_strips_ansi() { + assert_eq!(ansi_visible_text("plain"), "plain"); + assert_eq!(ansi_visible_text("\x1b[31mred\x1b[0m"), "red"); + assert_eq!( + ansi_visible_text("\x1b[1;32mbold green\x1b[0m plain"), + "bold green plain" + ); + } + + #[test] + fn xterm_256_color_cube() { + assert_eq!(ansi_256(16), Color::Rgb(0, 0, 0)); + assert_eq!(ansi_256(21), Color::Rgb(0, 0, 255)); + assert_eq!(ansi_256(52), Color::Rgb(95, 0, 0)); + assert_eq!(ansi_256(67), Color::Rgb(95, 135, 175)); + } + + #[test] + fn true_color_24bit() { + let spans = ansi_to_spans( + "\x1b[38;2;100;200;50mtruecolor\x1b[0m", + Style::default(), + ); + assert_eq!(visible(&spans), "truecolor"); + assert_eq!(spans[0].style.fg, Some(Color::Rgb(100, 200, 50))); + } +} diff --git a/crates/pipeview-tui/src/app.rs b/crates/pipeview-tui/src/app.rs index 575e695..5e12e2f 100644 --- a/crates/pipeview-tui/src/app.rs +++ b/crates/pipeview-tui/src/app.rs @@ -1,20 +1,33 @@ -use std::{io, time::Duration}; +use std::io; +use std::time::Duration; -use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; -use ratatui::DefaultTerminal; -use tokio::sync::broadcast; +use crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; use pipeview_client::{ - DecoderConfig, FramerConfig, PipelineConfig, SessionConfig, SessionEvent, SessionId, - SessionManager, + DecoderConfig, FramerConfig, PipelineConfig, SessionConfig, SessionEvent, SessionHandle, + SessionId, SessionManager, }; use pipeview_core::protocol::DecodedData; -use pipeview_core::transport::{ - TransportConfig, TransportType, - serial::{SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport}, +use pipeview_core::protocol::Endian; +use pipeview_core::protocol::plot::{PlotFormat, SampleType}; +use pipeview_core::protocol::text::TextEncoding; +use pipeview_core::transport::TransportConfig; +use pipeview_core::transport::serial::{ + SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits, SerialTransport, }; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Rect; +use tokio::sync::broadcast; -use crate::app_state::{self, PersistedTuiState}; -use crate::buffers::{HexBuffer, TextBuffer}; +use crate::ansi::ansi_visible_text; +use crate::app_state; +use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer}; + +const TICK_RATE: Duration = Duration::from_millis(33); +const DEFAULT_HISTORY_LIMIT: usize = 10_000; #[derive(Clone)] pub enum ConnectionStatus { @@ -25,20 +38,37 @@ pub enum ConnectionStatus { } impl ConnectionStatus { - pub fn badge(&self) -> &'static str { + pub fn label(&self) -> &'static str { match self { - Self::Connected => "connected", - Self::Disconnected => "disconnected", - Self::Connecting => "connecting", - Self::Error(_) => "error", + Self::Connected => "CONNECTED", + Self::Disconnected => "OFFLINE", + Self::Connecting => "CONNECTING", + Self::Error(_) => "ERROR", } } + + pub fn is_connectedish(&self) -> bool { + matches!(self, Self::Connected | Self::Connecting) + } } #[derive(Clone, Copy, PartialEq, Eq)] pub enum View { Text, Hex, + Plot, +} + +impl View { + pub const ALL: [Self; 3] = [Self::Text, Self::Hex, Self::Plot]; + + pub fn label(self) -> &'static str { + match self { + Self::Text => "Text", + Self::Hex => "Hex", + Self::Plot => "Plot", + } + } } #[derive(Clone, Copy, PartialEq, Eq)] @@ -47,6 +77,80 @@ pub enum SendMode { Hex, } +impl SendMode { + pub fn label(self) -> &'static str { + match self { + Self::Text => "Text", + Self::Hex => "Hex", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +#[allow(clippy::upper_case_acronyms)] +pub enum LineEnding { + None, + LF, + CR, + CRLF, +} + +impl LineEnding { + pub const ALL: [Self; 4] = [Self::None, Self::LF, Self::CR, Self::CRLF]; + + pub fn label(self) -> &'static str { + match self { + Self::None => "None", + Self::LF => "LF", + Self::CR => "CR", + Self::CRLF => "CRLF", + } + } + + fn bytes(self) -> &'static [u8] { + match self { + Self::None => b"", + Self::LF => b"\n", + Self::CR => b"\r", + Self::CRLF => b"\r\n", + } + } + + fn next(self) -> Self { + match self { + Self::None => Self::LF, + Self::LF => Self::CR, + Self::CR => Self::CRLF, + Self::CRLF => Self::None, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FocusPane { + Controls, + Main, + Composer, +} + +impl FocusPane { + fn next(self) -> Self { + match self { + Self::Controls => Self::Main, + Self::Main => Self::Composer, + Self::Composer => Self::Controls, + } + } + + fn prev(self) -> Self { + match self { + Self::Controls => Self::Composer, + Self::Main => Self::Controls, + Self::Composer => Self::Main, + } + } +} + #[derive(Clone, Copy)] pub struct DisplayOptions { pub show_timestamp: bool, @@ -54,34 +158,186 @@ pub struct DisplayOptions { pub show_pipeline: bool, } -pub struct SessionTab { +#[derive(Default)] +pub struct SearchState { + pub query: String, + pub matches: Vec, + pub current_match: usize, + pub case_sensitive: bool, + pub active: bool, +} + +impl SearchState { + pub fn current_line(&self) -> Option { + self.matches.get(self.current_match).copied() + } + + pub fn count(&self) -> usize { + self.matches.len() + } + + pub fn display_index(&self) -> usize { + if self.matches.is_empty() { + 0 + } else { + self.current_match + 1 + } + } +} + +#[derive(Clone, Copy)] +pub enum AppMode { + Normal, + EditingSend, + Search, + Config, + Help, +} + +pub struct SingleSession { pub id: SessionId, - pub session_config: SessionConfig, + pub config: SessionConfig, pub status: ConnectionStatus, pub console: TextBuffer, pub hex: HexBuffer, + pub plot: PlotBuffer, pub view: View, - pub auto_reconnect: bool, pub send_input: String, + pub input_cursor: usize, pub send_mode: SendMode, - pub append_newline: bool, - pub send_status: Option, + pub line_ending: LineEnding, + pub send_status: String, + pub auto_reconnect: bool, + pub dtr: bool, + pub rts: bool, + pub received_messages: usize, + pub sent_messages: usize, } -pub enum AppMode { - Normal, - SendInput, - SessionForm(SessionForm), +impl SingleSession { + fn new(id: SessionId, config: SessionConfig, view: View) -> Self { + let history_limit = config.history_limit; + let (dtr, rts) = match &config.transport { + TransportConfig::Serial { dtr, rts, .. } => (*dtr, *rts), + _ => (false, false), + }; + + Self { + id, + auto_reconnect: config.auto_reconnect, + config, + status: ConnectionStatus::Connecting, + console: TextBuffer::new(history_limit), + hex: HexBuffer::new(history_limit), + plot: PlotBuffer::new(history_limit), + view, + send_input: String::new(), + input_cursor: 0, + send_mode: SendMode::Text, + line_ending: LineEnding::LF, + send_status: String::from("idle"), + dtr, + rts, + received_messages: 0, + sent_messages: 0, + } + } } -pub enum SessionFormAction { - Create, - Edit(SessionId), +#[derive(Clone, Copy)] +pub enum TransportChoice { + Serial, + Tcp, + Udp, } -pub struct SessionForm { - pub action: SessionFormAction, - pub transport: TransportType, +impl TransportChoice { + fn label(self) -> &'static str { + match self { + Self::Serial => "Serial", + Self::Tcp => "TCP", + Self::Udp => "UDP", + } + } + + fn next(self, forward: bool) -> Self { + match (self, forward) { + (Self::Serial, true) => Self::Tcp, + (Self::Tcp, true) => Self::Udp, + (Self::Udp, true) => Self::Serial, + (Self::Serial, false) => Self::Udp, + (Self::Tcp, false) => Self::Serial, + (Self::Udp, false) => Self::Tcp, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PipelinePreset { + TextHexLines, + TextLines, + HexLines, + MixedTextPlot, + PlotF32Fixed, +} + +impl PipelinePreset { + fn label(self) -> &'static str { + match self { + Self::TextHexLines => "Text + Hex lines", + Self::TextLines => "Text lines", + Self::HexLines => "Hex lines", + Self::MixedTextPlot => "Mixed text/plot", + Self::PlotF32Fixed => "Plot f32 fixed", + } + } + + fn next(self, forward: bool) -> Self { + match (self, forward) { + (Self::TextHexLines, true) => Self::TextLines, + (Self::TextLines, true) => Self::HexLines, + (Self::HexLines, true) => Self::MixedTextPlot, + (Self::MixedTextPlot, true) => Self::PlotF32Fixed, + (Self::PlotF32Fixed, true) => Self::TextHexLines, + (Self::TextHexLines, false) => Self::PlotF32Fixed, + (Self::TextLines, false) => Self::TextHexLines, + (Self::HexLines, false) => Self::TextLines, + (Self::MixedTextPlot, false) => Self::HexLines, + (Self::PlotF32Fixed, false) => Self::MixedTextPlot, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ConfigField { + Transport, + TcpAddr, + UdpBind, + UdpRemote, + SerialPort, + SerialBaud, + SerialDataBits, + SerialParity, + SerialStopBits, + SerialFlowControl, + PipelinePreset, + PipelineName, + HistoryLimit, + AutoReconnect, + PlotFrameLen, + PlotChannels, + Apply, +} + +pub struct ConfigRow { + pub field: ConfigField, + pub label: &'static str, + pub value: String, + pub editable: bool, +} + +pub struct ConfigForm { + pub transport: TransportChoice, pub tcp_addr: String, pub udp_bind_addr: String, pub udp_remote_addr: String, @@ -91,56 +347,54 @@ pub struct SessionForm { pub serial_parity: SerialParity, pub serial_stop_bits: SerialStopBits, pub serial_flow_control: SerialFlowControl, + pub pipeline_preset: PipelinePreset, pub pipeline_name: String, + pub history_limit: String, pub auto_reconnect: bool, - pub focused_field: usize, + pub plot_frame_len: String, + pub plot_channels: String, + pub focused: usize, pub available_serial_ports: Vec, } -impl SessionForm { - fn new_create() -> Self { +impl ConfigForm { + fn from_session(config: &SessionConfig) -> Self { let available_serial_ports = available_serial_ports(); - let serial_port = available_serial_ports.first().cloned().unwrap_or_default(); - - Self { - action: SessionFormAction::Create, - transport: TransportType::Tcp, + let mut form = Self { + transport: TransportChoice::Tcp, tcp_addr: String::from("127.0.0.1:8080"), - udp_bind_addr: String::from("0.0.0.0:8080"), + udp_bind_addr: String::from("0.0.0.0:9000"), udp_remote_addr: String::new(), - serial_port, + serial_port: available_serial_ports.first().cloned().unwrap_or_default(), serial_baud_rate: String::from("115200"), serial_data_bits: SerialDataBits::Eight, serial_parity: SerialParity::None, serial_stop_bits: SerialStopBits::One, serial_flow_control: SerialFlowControl::None, - pipeline_name: String::from("text"), - auto_reconnect: false, - focused_field: 0, + pipeline_preset: infer_pipeline_preset(config), + pipeline_name: config + .pipelines + .first() + .map(|pipeline| pipeline.name.clone()) + .unwrap_or_else(|| String::from("text")), + history_limit: config.history_limit.to_string(), + auto_reconnect: config.auto_reconnect, + plot_frame_len: String::from("4"), + plot_channels: String::from("1"), + focused: 0, available_serial_ports, - } - } - - fn from_config(id: SessionId, config: &SessionConfig) -> Self { - let mut form = Self::new_create(); - form.action = SessionFormAction::Edit(id); - form.pipeline_name = config - .pipelines - .first() - .map(|pipeline| pipeline.name.clone()) - .unwrap_or_else(|| String::from("text")); - form.auto_reconnect = config.auto_reconnect; + }; match &config.transport { TransportConfig::Tcp { addr } => { - form.transport = TransportType::Tcp; + form.transport = TransportChoice::Tcp; form.tcp_addr = addr.clone(); } TransportConfig::Udp { bind_addr, remote_addr, } => { - form.transport = TransportType::Udp; + form.transport = TransportChoice::Udp; form.udp_bind_addr = bind_addr.clone(); form.udp_remote_addr = remote_addr.clone().unwrap_or_default(); } @@ -153,7 +407,7 @@ impl SessionForm { flow_control, .. } => { - form.transport = TransportType::Serial; + form.transport = TransportChoice::Serial; form.serial_port = port.clone(); form.serial_baud_rate = baud_rate.to_string(); form.serial_data_bits = *data_bits; @@ -163,122 +417,161 @@ impl SessionForm { } } + if let Some(pipeline) = config.pipelines.first() { + if let FramerConfig::Fixed { frame_len } = &pipeline.framer { + form.plot_frame_len = frame_len.to_string(); + } + if let DecoderConfig::Plot { channels, .. } = &pipeline.decoder { + form.plot_channels = channels.to_string(); + } + } + form } - pub fn title(&self) -> &'static str { - match self.action { - SessionFormAction::Create => "New Session", - SessionFormAction::Edit(_) => "Edit Session", - } + pub fn rows(&self) -> Vec { + self.fields() + .into_iter() + .map(|field| ConfigRow { + field, + label: field.label(), + value: self.field_value(field), + editable: self.is_text_field(field), + }) + .collect() } - pub fn rows(&self) -> Vec { + pub fn focused_field(&self) -> ConfigField { + let fields = self.fields(); + fields[self.focused.min(fields.len().saturating_sub(1))] + } + + fn fields(&self) -> Vec { + let mut fields = vec![ConfigField::Transport]; match self.transport { - TransportType::Tcp => vec![ - format!("Transport: {}", transport_name(self.transport)), - format!("Addr: {}", self.tcp_addr), - format!("Pipeline: {}", self.pipeline_name), - format!("Auto Reconnect: {}", self.auto_reconnect), - ], - TransportType::Udp => vec![ - format!("Transport: {}", transport_name(self.transport)), - format!("Bind Addr: {}", self.udp_bind_addr), - format!("Remote Addr: {}", self.udp_remote_addr), - format!("Pipeline: {}", self.pipeline_name), - format!("Auto Reconnect: {}", self.auto_reconnect), - ], - TransportType::Serial => vec![ - format!("Transport: {}", transport_name(self.transport)), - format!("Port: {}", self.serial_port), - format!("Baud Rate: {}", self.serial_baud_rate), - format!( - "Data Bits: {}", - serial_data_bits_name(self.serial_data_bits) - ), - format!("Parity: {}", serial_parity_name(self.serial_parity)), - format!( - "Stop Bits: {}", - serial_stop_bits_name(self.serial_stop_bits) - ), - format!( - "Flow Control: {}", - serial_flow_control_name(self.serial_flow_control) - ), - format!("Pipeline: {}", self.pipeline_name), - format!("Auto Reconnect: {}", self.auto_reconnect), - ], - } - } - - pub fn footer_lines(&self) -> Vec { - let mut lines = vec![ - String::from("Framer: Line, Decoder: Text, History: 1000"), - String::from("Tab move, Left/Right change, Enter submit, Esc cancel"), - ]; - if matches!(self.transport, TransportType::Serial) { - if self.available_serial_ports.is_empty() { - lines.push(String::from("Ports: no serial ports found")); - } else { - lines.push(format!("Ports: {}", self.available_serial_ports.join(", "))); + TransportChoice::Tcp => fields.push(ConfigField::TcpAddr), + TransportChoice::Udp => { + fields.push(ConfigField::UdpBind); + fields.push(ConfigField::UdpRemote); + } + TransportChoice::Serial => { + fields.push(ConfigField::SerialPort); + fields.push(ConfigField::SerialBaud); + fields.push(ConfigField::SerialDataBits); + fields.push(ConfigField::SerialParity); + fields.push(ConfigField::SerialStopBits); + fields.push(ConfigField::SerialFlowControl); } } - lines + fields.push(ConfigField::PipelinePreset); + fields.push(ConfigField::PipelineName); + fields.push(ConfigField::HistoryLimit); + fields.push(ConfigField::AutoReconnect); + if self.pipeline_preset == PipelinePreset::PlotF32Fixed { + fields.push(ConfigField::PlotFrameLen); + fields.push(ConfigField::PlotChannels); + } + fields.push(ConfigField::Apply); + fields } - fn field_count(&self) -> usize { - self.rows().len() + fn field_value(&self, field: ConfigField) -> String { + match field { + ConfigField::Transport => self.transport.label().to_string(), + ConfigField::TcpAddr => self.tcp_addr.clone(), + ConfigField::UdpBind => self.udp_bind_addr.clone(), + ConfigField::UdpRemote => { + if self.udp_remote_addr.is_empty() { + String::from("") + } else { + self.udp_remote_addr.clone() + } + } + ConfigField::SerialPort => { + if self.serial_port.is_empty() { + String::from("") + } else { + self.serial_port.clone() + } + } + ConfigField::SerialBaud => self.serial_baud_rate.clone(), + ConfigField::SerialDataBits => serial_data_bits_name(self.serial_data_bits).to_string(), + ConfigField::SerialParity => serial_parity_name(self.serial_parity).to_string(), + ConfigField::SerialStopBits => serial_stop_bits_name(self.serial_stop_bits).to_string(), + ConfigField::SerialFlowControl => { + serial_flow_control_name(self.serial_flow_control).to_string() + } + ConfigField::PipelinePreset => self.pipeline_preset.label().to_string(), + ConfigField::PipelineName => self.pipeline_name.clone(), + ConfigField::HistoryLimit => self.history_limit.clone(), + ConfigField::AutoReconnect => bool_label(self.auto_reconnect).to_string(), + ConfigField::PlotFrameLen => self.plot_frame_len.clone(), + ConfigField::PlotChannels => self.plot_channels.clone(), + ConfigField::Apply => String::from("Apply and reconnect"), + } + } + + fn is_text_field(&self, field: ConfigField) -> bool { + matches!( + field, + ConfigField::TcpAddr + | ConfigField::UdpBind + | ConfigField::UdpRemote + | ConfigField::SerialPort + | ConfigField::SerialBaud + | ConfigField::PipelineName + | ConfigField::HistoryLimit + | ConfigField::PlotFrameLen + | ConfigField::PlotChannels + ) + } + + fn focus_field(&mut self, field: ConfigField) { + if let Some(index) = self.fields().into_iter().position(|item| item == field) { + self.focused = index; + } } fn next_field(&mut self) { - self.focused_field = (self.focused_field + 1) % self.field_count(); + let count = self.fields().len(); + self.focused = (self.focused + 1) % count; } fn prev_field(&mut self) { - self.focused_field = if self.focused_field == 0 { - self.field_count() - 1 + let count = self.fields().len(); + self.focused = if self.focused == 0 { + count.saturating_sub(1) } else { - self.focused_field - 1 + self.focused - 1 }; } - fn cycle_transport(&mut self, forward: bool) { - self.transport = match (self.transport, forward) { - (TransportType::Serial, true) => TransportType::Tcp, - (TransportType::Tcp, true) => TransportType::Udp, - (TransportType::Udp, true) => TransportType::Serial, - (TransportType::Serial, false) => TransportType::Udp, - (TransportType::Tcp, false) => TransportType::Serial, - (TransportType::Udp, false) => TransportType::Tcp, - }; - self.focused_field = self.focused_field.min(self.field_count() - 1); - } - fn cycle_current_option(&mut self, forward: bool) { - match self.transport { - TransportType::Tcp => match self.focused_field { - 0 => self.cycle_transport(forward), - 3 => self.auto_reconnect = !self.auto_reconnect, - _ => {} - }, - TransportType::Udp => match self.focused_field { - 0 => self.cycle_transport(forward), - 4 => self.auto_reconnect = !self.auto_reconnect, - _ => {} - }, - TransportType::Serial => match self.focused_field { - 0 => self.cycle_transport(forward), - 1 => self.cycle_serial_port(forward), - 3 => self.serial_data_bits = next_serial_data_bits(self.serial_data_bits, forward), - 4 => self.serial_parity = next_serial_parity(self.serial_parity, forward), - 5 => self.serial_stop_bits = next_serial_stop_bits(self.serial_stop_bits, forward), - 6 => { - self.serial_flow_control = - next_serial_flow_control(self.serial_flow_control, forward) - } - 8 => self.auto_reconnect = !self.auto_reconnect, - _ => {} - }, + match self.focused_field() { + ConfigField::Transport => { + self.transport = self.transport.next(forward); + self.focused = self.focused.min(self.fields().len().saturating_sub(1)); + } + ConfigField::SerialPort => self.cycle_serial_port(forward), + ConfigField::SerialDataBits => { + self.serial_data_bits = next_serial_data_bits(self.serial_data_bits, forward); + } + ConfigField::SerialParity => { + self.serial_parity = next_serial_parity(self.serial_parity, forward); + } + ConfigField::SerialStopBits => { + self.serial_stop_bits = next_serial_stop_bits(self.serial_stop_bits, forward); + } + ConfigField::SerialFlowControl => { + self.serial_flow_control = + next_serial_flow_control(self.serial_flow_control, forward); + } + ConfigField::PipelinePreset => { + self.pipeline_preset = self.pipeline_preset.next(forward); + self.focused = self.focused.min(self.fields().len().saturating_sub(1)); + } + ConfigField::AutoReconnect => self.auto_reconnect = !self.auto_reconnect, + _ => {} } } @@ -287,50 +580,48 @@ impl SessionForm { return; } - let ports = &self.available_serial_ports; - let next_index = ports + let next_index = self + .available_serial_ports .iter() .position(|port| port == &self.serial_port) .map(|index| { if forward { - (index + 1) % ports.len() + (index + 1) % self.available_serial_ports.len() } else if index == 0 { - ports.len() - 1 + self.available_serial_ports.len() - 1 } else { index - 1 } }) .unwrap_or(0); - self.serial_port = ports[next_index].clone(); + self.serial_port = self.available_serial_ports[next_index].clone(); } fn focused_text_field_mut(&mut self) -> Option<&mut String> { - match self.transport { - TransportType::Tcp => match self.focused_field { - 1 => Some(&mut self.tcp_addr), - 2 => Some(&mut self.pipeline_name), - _ => None, - }, - TransportType::Udp => match self.focused_field { - 1 => Some(&mut self.udp_bind_addr), - 2 => Some(&mut self.udp_remote_addr), - 3 => Some(&mut self.pipeline_name), - _ => None, - }, - TransportType::Serial => match self.focused_field { - 1 => Some(&mut self.serial_port), - 2 => Some(&mut self.serial_baud_rate), - 7 => Some(&mut self.pipeline_name), - _ => None, - }, + match self.focused_field() { + ConfigField::TcpAddr => Some(&mut self.tcp_addr), + ConfigField::UdpBind => Some(&mut self.udp_bind_addr), + ConfigField::UdpRemote => Some(&mut self.udp_remote_addr), + ConfigField::SerialPort => Some(&mut self.serial_port), + ConfigField::SerialBaud => Some(&mut self.serial_baud_rate), + ConfigField::PipelineName => Some(&mut self.pipeline_name), + ConfigField::HistoryLimit => Some(&mut self.history_limit), + ConfigField::PlotFrameLen => Some(&mut self.plot_frame_len), + ConfigField::PlotChannels => Some(&mut self.plot_channels), + _ => None, } } fn accept_char(&mut self, c: char) { - let serial_baud_field = - matches!(self.transport, TransportType::Serial) && self.focused_field == 2; + let numeric = matches!( + self.focused_field(), + ConfigField::SerialBaud + | ConfigField::HistoryLimit + | ConfigField::PlotFrameLen + | ConfigField::PlotChannels + ); if let Some(field) = self.focused_text_field_mut() { - if serial_baud_field && !c.is_ascii_digit() { + if numeric && !c.is_ascii_digit() { return; } field.push(c); @@ -344,649 +635,1035 @@ impl SessionForm { field.pop(); } } + + fn to_session_config(&self, dtr: bool, rts: bool) -> Result { + let history_limit = parse_usize(&self.history_limit, "history limit")?.max(1); + let pipeline_name = self.pipeline_name.trim(); + if pipeline_name.is_empty() { + return Err(String::from("pipeline name cannot be empty")); + } + + let transport = match self.transport { + TransportChoice::Tcp => { + let addr = self.tcp_addr.trim(); + if addr.is_empty() { + return Err(String::from("tcp addr cannot be empty")); + } + TransportConfig::Tcp { + addr: addr.to_string(), + } + } + TransportChoice::Udp => { + let bind_addr = self.udp_bind_addr.trim(); + if bind_addr.is_empty() { + return Err(String::from("udp bind addr cannot be empty")); + } + let remote_addr = if self.udp_remote_addr.trim().is_empty() { + None + } else { + Some(self.udp_remote_addr.trim().to_string()) + }; + TransportConfig::Udp { + bind_addr: bind_addr.to_string(), + remote_addr, + } + } + TransportChoice::Serial => { + let port = self.serial_port.trim(); + if port.is_empty() { + return Err(String::from("serial port cannot be empty")); + } + TransportConfig::Serial { + port: port.to_string(), + baud_rate: parse_u32(&self.serial_baud_rate, "serial baud rate")?, + data_bits: self.serial_data_bits, + parity: self.serial_parity, + stop_bits: self.serial_stop_bits, + flow_control: self.serial_flow_control, + dtr, + rts, + } + } + }; + + Ok(SessionConfig { + transport, + pipelines: build_pipelines( + self.pipeline_preset, + pipeline_name, + parse_usize(&self.plot_frame_len, "plot frame length")?, + parse_usize(&self.plot_channels, "plot channels")?.max(1), + ), + history_limit, + auto_reconnect: self.auto_reconnect, + }) + } +} + +impl ConfigField { + fn label(self) -> &'static str { + match self { + Self::Transport => "Transport", + Self::TcpAddr => "TCP address", + Self::UdpBind => "UDP bind", + Self::UdpRemote => "UDP remote", + Self::SerialPort => "Serial port", + Self::SerialBaud => "Baud rate", + Self::SerialDataBits => "Data bits", + Self::SerialParity => "Parity", + Self::SerialStopBits => "Stop bits", + Self::SerialFlowControl => "Flow control", + Self::PipelinePreset => "Pipeline", + Self::PipelineName => "Pipeline name", + Self::HistoryLimit => "History", + Self::AutoReconnect => "Auto reconnect", + Self::PlotFrameLen => "Plot frame bytes", + Self::PlotChannels => "Plot channels", + Self::Apply => "Commit", + } + } +} + +pub struct PlotViewState { + pub follow_latest: bool, + pub x_window: f64, +} + +impl Default for PlotViewState { + fn default() -> Self { + Self { + follow_latest: true, + x_window: 500.0, + } + } +} + +#[derive(Clone, Copy)] +pub enum HotAction { + Focus(FocusPane), + View(View), + ToggleConnection, + Reconnect, + Clear, + OpenConfig, + OpenSearch, + OpenHelp, + Send, + ToggleAutoReconnect, + ToggleDtr, + ToggleRts, + ToggleTimestamp, + ToggleDirection, + TogglePipeline, + SendMode(SendMode), + LineEnding(LineEnding), + PlotFollow, + PlotZoomIn, + PlotZoomOut, + ConfigFocus(ConfigField), + ConfigSubmit, + ConfigCancel, + CloseModal, +} + +#[derive(Clone, Copy)] +pub struct HotZone { + pub area: Rect, + pub action: HotAction, } pub struct App { - should_quit: bool, - mode: AppMode, - manager: SessionManager, - tabs: Vec, - active: usize, - display: DisplayOptions, + pub should_quit: bool, + pub mode: AppMode, + pub manager: SessionManager, + pub handle: SessionHandle, + pub session: SingleSession, + pub display: DisplayOptions, + pub search: SearchState, + pub config_form: ConfigForm, + pub focus: FocusPane, + pub scroll_from_bottom: usize, + pub plot: PlotViewState, + pub notice: String, + pub hot_zones: Vec, session_rx: broadcast::Receiver, - notice: Option, } impl App { - pub fn new(manager: SessionManager, session_rx: broadcast::Receiver) -> Self { + pub fn new() -> Self { let saved = app_state::load_tui_state(); - let mut app = Self { + let manager = SessionManager::new(); + let session_rx = manager.subscribe(); + let handle = manager.create(saved.session.clone()); + let display = DisplayOptions { + show_timestamp: saved.show_timestamp, + show_direction: saved.show_direction, + show_pipeline: saved.show_pipeline, + }; + let session = + SingleSession::new(handle.id(), saved.session.clone(), saved.active_view.into()); + let config_form = ConfigForm::from_session(&saved.session); + + Self { should_quit: false, mode: AppMode::Normal, manager, - tabs: Vec::new(), - active: 0, - display: DisplayOptions { - show_timestamp: saved.show_timestamp, - show_direction: saved.show_direction, - show_pipeline: saved.show_pipeline, - }, + handle, + session, + display, + search: SearchState::default(), + config_form, + focus: FocusPane::Main, + scroll_from_bottom: 0, + plot: PlotViewState::default(), + notice: String::from("Ready"), + hot_zones: Vec::new(), session_rx, - notice: Some(String::from( - "n new, e edit, i input, c connect, x delete, q quit", - )), - }; - app.restore_saved_sessions(saved); - app + } } - pub fn should_quit(&self) -> bool { - self.should_quit - } + pub async fn run( + &mut self, + terminal: &mut Terminal>, + ) -> io::Result<()> { + loop { + self.drain_events(); + terminal.draw(|frame| crate::ui::render(frame, self))?; - pub fn mode(&self) -> &AppMode { - &self.mode - } - - pub fn display(&self) -> DisplayOptions { - self.display - } - - pub fn tabs(&self) -> &[SessionTab] { - &self.tabs - } - - pub fn active_index(&self) -> usize { - self.active - } - - pub fn active_tab(&self) -> Option<&SessionTab> { - self.tabs.get(self.active) - } - - pub fn notice(&self) -> Option<&str> { - self.notice.as_deref() - } - - pub fn help_text(&self) -> &'static str { - match self.mode { - AppMode::Normal => { - "j/k select n new e edit x delete c connect r reconnect C clear 1 text 2 hex i input m mode a newline t/o/p display q quit" + if self.should_quit { + break; } - AppMode::SendInput => "type input Enter send Esc stop editing Backspace delete", - AppMode::SessionForm(_) => { - "Tab/Shift+Tab move Left/Right change Enter submit Esc cancel" + + if event::poll(TICK_RATE)? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key(key), + Event::Mouse(mouse) => self.on_mouse(mouse), + Event::Resize(_, _) => {} + _ => {} + } } } + + Ok(()) } - pub fn transport_summary(config: &SessionConfig) -> String { - match &config.transport { - TransportConfig::Serial { port, .. } => format!("Serial {port}"), - TransportConfig::Tcp { addr } => format!("TCP {addr}"), - TransportConfig::Udp { - bind_addr, - remote_addr, - } => match remote_addr { - Some(remote) => format!("UDP {bind_addr} -> {remote}"), - None => format!("UDP {bind_addr}"), - }, + pub async fn shutdown(&mut self) { + let _ = self.handle.close().await; + self.manager.remove(self.session.id); + } + + pub fn reset_hot_zones(&mut self) { + self.hot_zones.clear(); + } + + pub fn add_hot_zone(&mut self, area: Rect, action: HotAction) { + if area.width > 0 && area.height > 0 { + self.hot_zones.push(HotZone { area, action }); } } - fn restore_saved_sessions(&mut self, saved: PersistedTuiState) { - for session_config in saved.sessions { - self.add_session_tab(session_config); - } - if !self.tabs.is_empty() { - self.active = saved.active.min(self.tabs.len() - 1); + pub fn active_line_count(&self) -> usize { + match self.session.view { + View::Text => self.session.console.len(), + View::Hex => self.session.hex.len(), + View::Plot => 0, } } - fn persist_state(&self) { - app_state::save_tui_state(&PersistedTuiState { - sessions: self - .tabs - .iter() - .map(|tab| tab.session_config.clone()) - .collect(), - active: if self.tabs.is_empty() { - 0 - } else { - self.active.min(self.tabs.len() - 1) - }, - show_timestamp: self.display.show_timestamp, - show_direction: self.display.show_direction, - show_pipeline: self.display.show_pipeline, - }); - } - - fn set_notice(&mut self, message: impl Into) { - self.notice = Some(message.into()); - } - - fn add_session_tab(&mut self, session_config: SessionConfig) { - let history_limit = session_config.history_limit; - let auto_reconnect = session_config.auto_reconnect; - let handle = self.manager.create(session_config.clone()); - self.tabs.push(SessionTab { - id: handle.id(), - session_config, - status: ConnectionStatus::Connecting, - console: TextBuffer::new(history_limit), - hex: HexBuffer::new(history_limit), - view: View::Text, - auto_reconnect, - send_input: String::new(), - send_mode: SendMode::Text, - append_newline: true, - send_status: None, - }); - } - - fn open_create_form(&mut self) { - self.mode = AppMode::SessionForm(SessionForm::new_create()); - } - - fn open_edit_form(&mut self) { - let Some(tab) = self.active_tab() else { - self.set_notice("no session selected"); - return; - }; - self.mode = AppMode::SessionForm(SessionForm::from_config(tab.id, &tab.session_config)); - } - - fn apply_session_config(&mut self, id: SessionId, session_config: SessionConfig) { - let handle = self.manager.get(id); - if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) { - let history_limit = session_config.history_limit; - tab.session_config = session_config.clone(); - tab.auto_reconnect = session_config.auto_reconnect; - tab.console.set_limit(history_limit); - tab.hex.set_limit(history_limit); - tab.status = ConnectionStatus::Connecting; - tab.send_status = Some(String::from("Session reconfigured")); - } - self.persist_state(); - - if let Some(handle) = handle { - tokio::spawn(async move { - let _ = handle.reconfigure(session_config).await; - }); - } else if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) { - tab.status = ConnectionStatus::Error(String::from("session not found")); + pub fn visible_start(&self, total: usize, viewport_height: usize) -> usize { + if viewport_height == 0 || total <= viewport_height { + return 0; } + total + .saturating_sub(viewport_height) + .saturating_sub(self.scroll_from_bottom) } - fn delete_active_session(&mut self) { - if self.tabs.is_empty() { - self.set_notice("no session to delete"); - return; - } - - let tab = self.tabs.remove(self.active); - self.manager.remove(tab.id); - if self.active >= self.tabs.len() && !self.tabs.is_empty() { - self.active = self.tabs.len() - 1; - } - self.persist_state(); - self.set_notice(format!("deleted session {}", tab.id)); + pub fn persist_state(&self) { + app_state::save_tui_state(&self.session.config, self.session.view, self.display); } - fn clear_active_buffers(&mut self) { - if let Some(tab) = self.tabs.get_mut(self.active) { - tab.console.clear(); - tab.hex.clear(); - tab.send_status = Some(String::from("Cleared")); + pub fn transport_summary(&self) -> String { + transport_summary(&self.session.config.transport) + } + + pub fn pipeline_summary(&self) -> String { + let names = self + .session + .config + .pipelines + .iter() + .map(|pipeline| pipeline.name.as_str()) + .collect::>(); + if names.is_empty() { + String::from("no pipelines") } else { - self.set_notice("no session selected"); + names.join(", ") } } + pub fn set_notice(&mut self, message: impl Into) { + self.notice = message.into(); + } + + pub fn plot_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> { + let (min, max) = self.session.plot.bounds()?; + if self.plot.follow_latest && max[0].is_finite() && self.plot.x_window > 0.0 { + let min_x = (max[0] - self.plot.x_window).max(min[0]); + Some(([min_x, min[1]], max)) + } else { + Some((min, max)) + } + } + + fn drain_events(&mut self) { + while let Ok(event) = self.session_rx.try_recv() { + match event { + SessionEvent::Connected(id) if id == self.session.id => { + self.session.status = ConnectionStatus::Connected; + self.set_notice("Connected"); + } + SessionEvent::Disconnected(id) | SessionEvent::Closed(id) + if id == self.session.id => + { + self.session.status = ConnectionStatus::Disconnected; + self.set_notice("Disconnected"); + } + SessionEvent::Error(id, message) if id == self.session.id => { + self.session.status = ConnectionStatus::Error(message.clone()); + self.set_notice(message); + } + SessionEvent::Data(id, entry) if id == self.session.id => { + self.session.received_messages += 1; + match &entry.data { + DecodedData::Text(_) => self.session.console.push(&entry), + DecodedData::Hex(_) => self.session.hex.push(&entry), + DecodedData::Plot(_) => self.session.plot.push(&entry), + DecodedData::Binary(bytes) => { + self.session.send_status = + format!("received binary frame: {} bytes", bytes.len()); + } + } + if self.search.active && !self.search.query.is_empty() { + self.rebuild_search(); + } + } + _ => {} + } + } + } + + fn on_key(&mut self, key: KeyEvent) { + match self.mode { + AppMode::Normal => self.on_normal_key(key), + AppMode::EditingSend => self.on_send_key(key), + AppMode::Search => self.on_search_key(key), + AppMode::Config => self.on_config_key(key), + AppMode::Help => { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('?') | KeyCode::Enter) { + self.mode = AppMode::Normal; + } + } + } + } + + fn on_normal_key(&mut self, key: KeyEvent) { + if key.modifiers == KeyModifiers::CONTROL { + match key.code { + KeyCode::Char('s') => self.send_active_input(), + KeyCode::Char('l') => self.clear_buffers(), + KeyCode::Char('f') => self.open_search(), + _ => {} + } + return; + } + + match key.code { + KeyCode::Char('q') => self.should_quit = true, + KeyCode::Char('?') => self.mode = AppMode::Help, + KeyCode::Esc => { + self.search.active = false; + self.set_notice("Search cleared"); + } + KeyCode::Tab => self.focus = self.focus.next(), + KeyCode::BackTab => self.focus = self.focus.prev(), + KeyCode::Char('1') => self.set_view(View::Text), + KeyCode::Char('2') => self.set_view(View::Hex), + KeyCode::Char('3') => self.set_view(View::Plot), + KeyCode::Char('c') => self.toggle_connection(), + KeyCode::Char('r') => self.reconnect(), + KeyCode::Char('e') => self.open_config(), + KeyCode::Char('/') => self.open_search(), + KeyCode::Char('x') => self.clear_buffers(), + KeyCode::Char('m') => self.toggle_send_mode(), + KeyCode::Char('l') => self.cycle_line_ending(), + KeyCode::Char('a') => self.toggle_auto_reconnect(), + KeyCode::Char('d') => self.toggle_dtr(), + KeyCode::Char('s') => self.toggle_rts(), + KeyCode::Char('t') => self.toggle_timestamp(), + KeyCode::Char('o') => self.toggle_direction(), + KeyCode::Char('p') => self.toggle_pipeline_display(), + KeyCode::Char('f') if self.session.view == View::Plot => self.toggle_plot_follow(), + KeyCode::Char('+') | KeyCode::Char('=') if self.session.view == View::Plot => { + self.zoom_plot(true); + } + KeyCode::Char('-') if self.session.view == View::Plot => self.zoom_plot(false), + KeyCode::Char('n') if self.search.active => self.next_match(), + KeyCode::Char('N') if self.search.active => self.prev_match(), + KeyCode::Up | KeyCode::Char('k') => self.scroll_up(1), + KeyCode::Down | KeyCode::Char('j') => self.scroll_down(1), + KeyCode::PageUp => self.scroll_up(10), + KeyCode::PageDown => self.scroll_down(10), + KeyCode::Home => self.scroll_to_top(), + KeyCode::End => self.scroll_to_bottom(), + KeyCode::Enter if self.focus == FocusPane::Composer => self.mode = AppMode::EditingSend, + KeyCode::Char(c) if self.focus == FocusPane::Composer && key.modifiers.is_empty() => { + self.mode = AppMode::EditingSend; + self.insert_send_char(c); + } + _ => {} + } + } + + fn on_send_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => self.mode = AppMode::Normal, + KeyCode::Enter if key.modifiers == KeyModifiers::CONTROL => self.insert_send_char('\n'), + KeyCode::Enter => { + self.send_active_input(); + self.mode = AppMode::Normal; + } + KeyCode::Backspace => self.backspace_send_input(), + KeyCode::Delete => self.delete_send_input(), + KeyCode::Left => self.move_send_cursor_left(), + KeyCode::Right => self.move_send_cursor_right(), + KeyCode::Home => self.session.input_cursor = 0, + KeyCode::End => self.session.input_cursor = self.send_input_char_len(), + KeyCode::Tab => self.insert_send_char('\t'), + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + self.insert_send_char(c); + } + _ => {} + } + } + + fn on_search_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => self.mode = AppMode::Normal, + KeyCode::Enter => { + self.search.active = !self.search.query.is_empty(); + self.mode = AppMode::Normal; + self.focus_current_match(); + } + KeyCode::Backspace => { + self.search.query.pop(); + self.rebuild_search(); + } + KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => { + self.search.case_sensitive = !self.search.case_sensitive; + self.rebuild_search(); + } + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + self.search.query.push(c); + self.search.active = true; + self.rebuild_search(); + } + KeyCode::Down => self.next_match(), + KeyCode::Up => self.prev_match(), + _ => {} + } + } + + fn on_config_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => self.mode = AppMode::Normal, + KeyCode::Tab | KeyCode::Down => self.config_form.next_field(), + KeyCode::BackTab | KeyCode::Up => self.config_form.prev_field(), + KeyCode::Left => self.config_form.cycle_current_option(false), + KeyCode::Right => self.config_form.cycle_current_option(true), + KeyCode::Backspace => self.config_form.backspace(), + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + self.config_form.accept_char(c); + } + KeyCode::Enter => self.apply_config(), + _ => {} + } + } + + fn on_mouse(&mut self, mouse: MouseEvent) { + match mouse.kind { + MouseEventKind::ScrollUp => { + if self.session.view == View::Plot { + self.zoom_plot(true); + } else { + self.scroll_up(3); + } + } + MouseEventKind::ScrollDown => { + if self.session.view == View::Plot { + self.zoom_plot(false); + } else { + self.scroll_down(3); + } + } + MouseEventKind::Down(MouseButton::Left) => { + let action = self + .hot_zones + .iter() + .rev() + .find(|zone| point_in_rect(mouse.column, mouse.row, zone.area)) + .map(|zone| zone.action); + if let Some(action) = action { + self.execute_hot_action(action); + } + } + _ => {} + } + } + + fn execute_hot_action(&mut self, action: HotAction) { + match action { + HotAction::Focus(focus) => self.focus = focus, + HotAction::View(view) => self.set_view(view), + HotAction::ToggleConnection => self.toggle_connection(), + HotAction::Reconnect => self.reconnect(), + HotAction::Clear => self.clear_buffers(), + HotAction::OpenConfig => self.open_config(), + HotAction::OpenSearch => self.open_search(), + HotAction::OpenHelp => self.mode = AppMode::Help, + HotAction::Send => self.send_active_input(), + HotAction::ToggleAutoReconnect => self.toggle_auto_reconnect(), + HotAction::ToggleDtr => self.toggle_dtr(), + HotAction::ToggleRts => self.toggle_rts(), + HotAction::ToggleTimestamp => self.toggle_timestamp(), + HotAction::ToggleDirection => self.toggle_direction(), + HotAction::TogglePipeline => self.toggle_pipeline_display(), + HotAction::SendMode(mode) => self.session.send_mode = mode, + HotAction::LineEnding(ending) => self.session.line_ending = ending, + HotAction::PlotFollow => self.toggle_plot_follow(), + HotAction::PlotZoomIn => self.zoom_plot(true), + HotAction::PlotZoomOut => self.zoom_plot(false), + HotAction::ConfigFocus(field) => { + self.mode = AppMode::Config; + self.config_form.focus_field(field); + } + HotAction::ConfigSubmit => self.apply_config(), + HotAction::ConfigCancel | HotAction::CloseModal => self.mode = AppMode::Normal, + } + } + + fn set_view(&mut self, view: View) { + self.session.view = view; + self.scroll_from_bottom = 0; + self.rebuild_search(); + self.persist_state(); + } + fn toggle_connection(&mut self) { - let Some(tab) = self.tabs.get_mut(self.active) else { - self.set_notice("no session selected"); - return; - }; - - let connected = matches!( - tab.status, - ConnectionStatus::Connected | ConnectionStatus::Connecting - ); - - let Some(handle) = self.manager.get(tab.id) else { - tab.status = ConnectionStatus::Error(String::from("session not found")); - return; - }; - - if connected { - tab.status = ConnectionStatus::Disconnected; + let handle = self.handle.clone(); + if self.session.status.is_connectedish() { + self.session.status = ConnectionStatus::Disconnected; + self.set_notice("Disconnect requested"); tokio::spawn(async move { let _ = handle.disconnect().await; }); } else { - tab.status = ConnectionStatus::Connecting; + self.session.status = ConnectionStatus::Connecting; + self.set_notice("Connect requested"); tokio::spawn(async move { let _ = handle.connect().await; }); } } - fn reconnect_active(&mut self) { - let Some(tab) = self.tabs.get_mut(self.active) else { - self.set_notice("no session selected"); - return; - }; - - let Some(handle) = self.manager.get(tab.id) else { - tab.status = ConnectionStatus::Error(String::from("session not found")); - return; - }; - - tab.status = ConnectionStatus::Connecting; + fn reconnect(&mut self) { + self.session.status = ConnectionStatus::Connecting; + self.set_notice("Reconnect requested"); + let handle = self.handle.clone(); tokio::spawn(async move { let _ = handle.reconnect().await; }); } - fn toggle_auto_reconnect_active(&mut self) { - let Some(tab) = self.tabs.get_mut(self.active) else { - self.set_notice("no session selected"); - return; - }; - tab.auto_reconnect = !tab.auto_reconnect; - tab.session_config.auto_reconnect = tab.auto_reconnect; - let session_id = tab.id; - let enabled = tab.auto_reconnect; - let missing_handle_error = ConnectionStatus::Error(String::from("session not found")); - let _ = tab; - self.persist_state(); + fn clear_buffers(&mut self) { + self.session.console.clear(); + self.session.hex.clear(); + self.session.plot.clear(); + self.scroll_from_bottom = 0; + self.search.matches.clear(); + self.session.send_status = String::from("cleared"); + self.set_notice("Buffers cleared"); + } - let Some(handle) = self.manager.get(session_id) else { - if let Some(tab) = self.tabs.get_mut(self.active) { - tab.status = missing_handle_error; - } - return; - }; - tokio::spawn(async move { - let _ = handle.set_auto_reconnect(enabled).await; - }); + fn open_config(&mut self) { + self.config_form = ConfigForm::from_session(&self.session.config); + self.mode = AppMode::Config; + } + + fn open_search(&mut self) { + self.mode = AppMode::Search; + self.search.active = true; + self.rebuild_search(); } fn toggle_send_mode(&mut self) { - let Some(tab) = self.tabs.get_mut(self.active) else { - self.set_notice("no session selected"); - return; - }; - tab.send_mode = match tab.send_mode { + self.session.send_mode = match self.session.send_mode { SendMode::Text => SendMode::Hex, SendMode::Hex => SendMode::Text, }; } - fn enter_send_input(&mut self) { - if self.tabs.is_empty() { - self.set_notice("no session selected"); + fn cycle_line_ending(&mut self) { + self.session.line_ending = self.session.line_ending.next(); + } + + fn toggle_auto_reconnect(&mut self) { + self.session.auto_reconnect = !self.session.auto_reconnect; + self.session.config.auto_reconnect = self.session.auto_reconnect; + let enabled = self.session.auto_reconnect; + self.persist_state(); + + let handle = self.handle.clone(); + tokio::spawn(async move { + let _ = handle.set_auto_reconnect(enabled).await; + }); + } + + fn toggle_dtr(&mut self) { + if !matches!( + self.session.config.transport, + TransportConfig::Serial { .. } + ) { + self.set_notice("DTR is only available for serial sessions"); return; } - self.mode = AppMode::SendInput; + + self.session.dtr = !self.session.dtr; + if let TransportConfig::Serial { dtr, .. } = &mut self.session.config.transport { + *dtr = self.session.dtr; + } + self.persist_state(); + + let handle = self.handle.clone(); + let state = self.session.dtr; + tokio::spawn(async move { + let _ = handle.set_dtr(state).await; + }); + } + + fn toggle_rts(&mut self) { + if !matches!( + self.session.config.transport, + TransportConfig::Serial { .. } + ) { + self.set_notice("RTS is only available for serial sessions"); + return; + } + + self.session.rts = !self.session.rts; + if let TransportConfig::Serial { rts, .. } = &mut self.session.config.transport { + *rts = self.session.rts; + } + self.persist_state(); + + let handle = self.handle.clone(); + let state = self.session.rts; + tokio::spawn(async move { + let _ = handle.set_rts(state).await; + }); + } + + fn toggle_timestamp(&mut self) { + self.display.show_timestamp = !self.display.show_timestamp; + self.persist_state(); + } + + fn toggle_direction(&mut self) { + self.display.show_direction = !self.display.show_direction; + self.persist_state(); + } + + fn toggle_pipeline_display(&mut self) { + self.display.show_pipeline = !self.display.show_pipeline; + self.persist_state(); + } + + fn toggle_plot_follow(&mut self) { + self.plot.follow_latest = !self.plot.follow_latest; + } + + fn zoom_plot(&mut self, zoom_in: bool) { + self.plot.x_window = if zoom_in { + (self.plot.x_window * 0.75).max(20.0) + } else { + (self.plot.x_window * 1.35).min(100_000.0) + }; + } + + fn apply_config(&mut self) { + let config = match self + .config_form + .to_session_config(self.session.dtr, self.session.rts) + { + Ok(config) => config, + Err(message) => { + self.set_notice(message); + return; + } + }; + + self.session.config = config.clone(); + self.session.auto_reconnect = config.auto_reconnect; + self.session.console.set_limit(config.history_limit); + self.session.hex.set_limit(config.history_limit); + self.session.plot.set_limit(config.history_limit); + self.session.status = ConnectionStatus::Connecting; + self.session.send_status = String::from("session reconfigured"); + self.mode = AppMode::Normal; + self.persist_state(); + + let handle = self.handle.clone(); + tokio::spawn(async move { + let _ = handle.reconfigure(config).await; + }); } fn send_active_input(&mut self) { - let Some(tab) = self.tabs.get_mut(self.active) else { - self.set_notice("no session selected"); - return; - }; - - let payload = match build_payload(tab) { + let payload = match self.build_payload() { Ok(Some(payload)) => payload, Ok(None) => { - tab.send_status = Some(String::from("Nothing to send")); + self.session.send_status = String::from("nothing to send"); return; } Err(message) => { - tab.send_status = Some(format!("Send failed: {message}")); + self.session.send_status = format!("send failed: {message}"); return; } }; - let Some(handle) = self.manager.get(tab.id) else { - tab.send_status = Some(String::from("Send failed: session not found")); - return; - }; - - match tab.send_mode { + match self.session.send_mode { SendMode::Text => { - let text = if tab.append_newline { - tab.send_input.trim_end_matches('\n').to_string() - } else { - tab.send_input.clone() - }; + let text = self.session.send_input.clone(); if !text.is_empty() { - tab.console.push_outbound(text); + self.session.console.push_outbound(text); } } SendMode::Hex => { - let hex = tab + let compact = self + .session .send_input .split_whitespace() .collect::>() .join(" "); - if !hex.is_empty() { - tab.hex.push_outbound(hex); + if !compact.is_empty() { + self.session.hex.push_outbound(compact); } } } + self.session.sent_messages += 1; + self.session.send_input.clear(); + self.session.input_cursor = 0; + self.session.send_status = String::from("sent"); + self.scroll_from_bottom = 0; + + let handle = self.handle.clone(); tokio::spawn(async move { let _ = handle.send(payload).await; }); - tab.send_input.clear(); - tab.send_status = Some(String::from("Sent")); } - fn drain_events(&mut self) { - while let Ok(event) = self.session_rx.try_recv() { - match event { - SessionEvent::Connected(id) => { - if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) { - tab.status = ConnectionStatus::Connected; - } - } - SessionEvent::Disconnected(id) | SessionEvent::Closed(id) => { - if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) { - tab.status = ConnectionStatus::Disconnected; - } - } - SessionEvent::Error(id, message) => { - if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) { - tab.status = ConnectionStatus::Error(message); - } - } - SessionEvent::Data(id, entry) => { - if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) { - match &entry.data { - DecodedData::Text(_) => tab.console.push(&entry), - DecodedData::Hex(_) => tab.hex.push(&entry), - DecodedData::Plot(_) | DecodedData::Binary(_) => {} - } - } + fn build_payload(&self) -> Result>, String> { + if self.session.send_input.trim().is_empty() { + return Ok(None); + } + + match self.session.send_mode { + SendMode::Text => { + let mut payload = self.session.send_input.clone().into_bytes(); + payload.extend_from_slice(self.session.line_ending.bytes()); + Ok(Some(payload)) + } + SendMode::Hex => { + let compact: String = self + .session + .send_input + .chars() + .filter(|ch| !ch.is_ascii_whitespace()) + .collect(); + if !compact.len().is_multiple_of(2) { + return Err(String::from("hex input needs an even number of digits")); } + hex::decode(compact) + .map(Some) + .map_err(|err| format!("invalid hex input ({err})")) } } } - #[allow(clippy::result_large_err)] - fn submit_session_form(&mut self, form: SessionForm) -> Result<(), (SessionForm, String)> { - let config = match build_session_config(&form) { - Ok(config) => config, - Err(err) => return Err((form, err)), + fn rebuild_search(&mut self) { + if self.search.query.is_empty() { + self.search.matches.clear(); + self.search.current_match = 0; + return; + } + + let query = if self.search.case_sensitive { + self.search.query.clone() + } else { + self.search.query.to_lowercase() }; - match form.action { - SessionFormAction::Create => { - self.add_session_tab(config); - self.active = self.tabs.len() - 1; - self.persist_state(); - self.set_notice("created session"); - } - SessionFormAction::Edit(id) => { - self.apply_session_config(id, config); - self.set_notice(format!("updated session {id}")); - } - } - self.mode = AppMode::Normal; - Ok(()) - } + let total = self.active_line_count(); + self.search.matches = (0..total) + .filter(|index| { + let line = match self.session.view { + View::Text => self + .session + .console + .get(*index) + .map(|line| ansi_visible_text(&line.text)) + .unwrap_or_default(), + View::Hex => self + .session + .hex + .get(*index) + .map(|line| format!("{} {}", line.hex, line.ascii)) + .unwrap_or_default(), + View::Plot => String::new(), + }; - fn on_normal_key(&mut self, code: KeyCode, modifiers: KeyModifiers) { - match code { - KeyCode::Char('q') => self.should_quit = true, - KeyCode::Char('j') if self.active + 1 < self.tabs.len() => { - self.active += 1; - self.persist_state(); - } - KeyCode::Char('k') if self.active > 0 => { - self.active -= 1; - self.persist_state(); - } - KeyCode::Char('n') => self.open_create_form(), - KeyCode::Char('e') => self.open_edit_form(), - KeyCode::Char('x') => self.delete_active_session(), - KeyCode::Char('c') => self.toggle_connection(), - KeyCode::Char('r') => self.reconnect_active(), - KeyCode::Char('C') => self.clear_active_buffers(), - KeyCode::Char('i') => self.enter_send_input(), - KeyCode::Char('m') => self.toggle_send_mode(), - KeyCode::Char('a') => { - if let Some(tab) = self.tabs.get_mut(self.active) { - tab.append_newline = !tab.append_newline; + if self.search.case_sensitive { + line.contains(&query) + } else { + line.to_lowercase().contains(&query) } - } - KeyCode::Char('u') => self.toggle_auto_reconnect_active(), - KeyCode::Char('1') => { - if let Some(tab) = self.tabs.get_mut(self.active) { - tab.view = View::Text; - } - } - KeyCode::Char('2') => { - if let Some(tab) = self.tabs.get_mut(self.active) { - tab.view = View::Hex; - } - } - KeyCode::Char('t') => { - self.display.show_timestamp = !self.display.show_timestamp; - self.persist_state(); - } - KeyCode::Char('o') => { - self.display.show_direction = !self.display.show_direction; - self.persist_state(); - } - KeyCode::Char('p') => { - self.display.show_pipeline = !self.display.show_pipeline; - self.persist_state(); - } - KeyCode::Enter if modifiers.is_empty() => self.enter_send_input(), - _ => {} + }) + .collect(); + + if self.search.current_match >= self.search.matches.len() { + self.search.current_match = self.search.matches.len().saturating_sub(1); } } - fn on_send_input_key(&mut self, code: KeyCode) { - let Some(tab) = self.tabs.get_mut(self.active) else { - self.mode = AppMode::Normal; + fn next_match(&mut self) { + if self.search.matches.is_empty() { + return; + } + self.search.current_match = (self.search.current_match + 1) % self.search.matches.len(); + self.focus_current_match(); + } + + fn prev_match(&mut self) { + if self.search.matches.is_empty() { + return; + } + self.search.current_match = if self.search.current_match == 0 { + self.search.matches.len() - 1 + } else { + self.search.current_match - 1 + }; + self.focus_current_match(); + } + + fn focus_current_match(&mut self) { + let Some(index) = self.search.current_line() else { return; }; - - match code { - KeyCode::Esc => self.mode = AppMode::Normal, - KeyCode::Backspace => { - tab.send_input.pop(); - } - KeyCode::Enter => { - self.send_active_input(); - self.mode = AppMode::Normal; - } - KeyCode::Char(c) => { - tab.send_input.push(c); - } - KeyCode::Tab => { - tab.send_input.push('\t'); - } - _ => {} - } + let total = self.active_line_count(); + self.scroll_from_bottom = total.saturating_sub(index + 1); } - fn on_form_key(&mut self, code: KeyCode) { - match code { - KeyCode::Esc => self.mode = AppMode::Normal, - KeyCode::Tab | KeyCode::Down => { - if let AppMode::SessionForm(form) = &mut self.mode { - form.next_field(); - } - } - KeyCode::BackTab | KeyCode::Up => { - if let AppMode::SessionForm(form) = &mut self.mode { - form.prev_field(); - } - } - KeyCode::Left => { - if let AppMode::SessionForm(form) = &mut self.mode { - form.cycle_current_option(false); - } - } - KeyCode::Right => { - if let AppMode::SessionForm(form) = &mut self.mode { - form.cycle_current_option(true); - } - } - KeyCode::Backspace => { - if let AppMode::SessionForm(form) = &mut self.mode { - form.backspace(); - } - } - KeyCode::Char(c) => { - if let AppMode::SessionForm(form) = &mut self.mode { - form.accept_char(c); - } - } - KeyCode::Enter => { - let mode = std::mem::replace(&mut self.mode, AppMode::Normal); - if let AppMode::SessionForm(form) = mode - && let Err((form, err)) = self.submit_session_form(form) - { - self.mode = AppMode::SessionForm(form); - self.set_notice(err); - } - } - _ => {} - } + fn scroll_up(&mut self, lines: usize) { + let total = self.active_line_count(); + self.scroll_from_bottom = (self.scroll_from_bottom + lines).min(total.saturating_sub(1)); } - pub fn on_key(&mut self, code: KeyCode, modifiers: KeyModifiers) { - match &self.mode { - AppMode::Normal => self.on_normal_key(code, modifiers), - AppMode::SendInput => self.on_send_input_key(code), - AppMode::SessionForm(_) => self.on_form_key(code), + fn scroll_down(&mut self, lines: usize) { + self.scroll_from_bottom = self.scroll_from_bottom.saturating_sub(lines); + } + + fn scroll_to_top(&mut self) { + self.scroll_from_bottom = self.active_line_count().saturating_sub(1); + } + + fn scroll_to_bottom(&mut self) { + self.scroll_from_bottom = 0; + } + + fn insert_send_char(&mut self, c: char) { + let byte_index = byte_index_for_char(&self.session.send_input, self.session.input_cursor); + self.session.send_input.insert(byte_index, c); + self.session.input_cursor += 1; + } + + fn backspace_send_input(&mut self) { + if self.session.input_cursor == 0 { + return; } + let remove_at = + byte_index_for_char(&self.session.send_input, self.session.input_cursor - 1); + self.session.send_input.remove(remove_at); + self.session.input_cursor -= 1; + } + + fn delete_send_input(&mut self) { + if self.session.input_cursor >= self.send_input_char_len() { + return; + } + let remove_at = byte_index_for_char(&self.session.send_input, self.session.input_cursor); + self.session.send_input.remove(remove_at); + } + + fn move_send_cursor_left(&mut self) { + self.session.input_cursor = self.session.input_cursor.saturating_sub(1); + } + + fn move_send_cursor_right(&mut self) { + self.session.input_cursor = (self.session.input_cursor + 1).min(self.send_input_char_len()); + } + + fn send_input_char_len(&self) -> usize { + self.session.send_input.chars().count() } } -pub fn run(terminal: &mut DefaultTerminal, mut app: App) -> io::Result<()> { - loop { - app.drain_events(); - terminal.draw(|frame| crate::ui::render(frame, &app))?; - - if event::poll(Duration::from_millis(100))? - && let Event::Key(key) = event::read()? - && key.kind == KeyEventKind::Press - { - app.on_key(key.code, key.modifiers); - } - - if app.should_quit() { - break; - } +pub fn default_session_config() -> SessionConfig { + SessionConfig { + transport: TransportConfig::Tcp { + addr: String::from("127.0.0.1:8080"), + }, + pipelines: build_pipelines(PipelinePreset::TextHexLines, "text", 4, 1), + history_limit: DEFAULT_HISTORY_LIMIT, + auto_reconnect: false, } - Ok(()) } -fn build_session_config(form: &SessionForm) -> Result { - let pipeline_name = form.pipeline_name.trim(); - if pipeline_name.is_empty() { - return Err(String::from("pipeline name cannot be empty")); - } - - let transport = match form.transport { - TransportType::Tcp => { - let addr = form.tcp_addr.trim(); - if addr.is_empty() { - return Err(String::from("tcp addr cannot be empty")); - } - TransportConfig::Tcp { - addr: addr.to_string(), - } - } - TransportType::Udp => { - let bind_addr = form.udp_bind_addr.trim(); - if bind_addr.is_empty() { - return Err(String::from("udp bind addr cannot be empty")); - } - let remote_addr = { - let remote = form.udp_remote_addr.trim(); - if remote.is_empty() { - None - } else { - Some(remote.to_string()) - } - }; - TransportConfig::Udp { - bind_addr: bind_addr.to_string(), - remote_addr, - } - } - TransportType::Serial => { - let port = form.serial_port.trim(); - if port.is_empty() { - return Err(String::from("serial port cannot be empty")); - } - let baud_rate = form - .serial_baud_rate - .trim() - .parse::() - .map_err(|_| String::from("serial baud rate must be a positive integer"))?; - TransportConfig::Serial { - port: port.to_string(), - baud_rate, - data_bits: form.serial_data_bits, - parity: form.serial_parity, - stop_bits: form.serial_stop_bits, - flow_control: form.serial_flow_control, - dtr: false, - rts: false, - } - } - }; - - Ok(SessionConfig { - transport, - pipelines: vec![PipelineConfig { +fn build_pipelines( + preset: PipelinePreset, + pipeline_name: &str, + plot_frame_len: usize, + plot_channels: usize, +) -> Vec { + match preset { + PipelinePreset::TextHexLines => vec![ + PipelineConfig { + name: String::from("text"), + framer: FramerConfig::Line { + strip_cr: true, + max_line_len: 1_048_576, + }, + decoder: DecoderConfig::Text { + encoding: TextEncoding::Utf8, + }, + }, + PipelineConfig { + name: String::from("hex"), + framer: FramerConfig::Line { + strip_cr: true, + max_line_len: 1_048_576, + }, + decoder: DecoderConfig::Hex { + uppercase: false, + separator: String::from(" "), + bytes_per_group: 1, + endian: Endian::Big, + }, + }, + ], + PipelinePreset::TextLines => vec![PipelineConfig { name: pipeline_name.to_string(), framer: FramerConfig::Line { strip_cr: true, - max_line_len: 4096, + max_line_len: 1_048_576, }, decoder: DecoderConfig::Text { - encoding: Default::default(), + encoding: TextEncoding::Utf8, }, }], - history_limit: 1000, - auto_reconnect: form.auto_reconnect, - }) + PipelinePreset::HexLines => vec![PipelineConfig { + name: pipeline_name.to_string(), + framer: FramerConfig::Line { + strip_cr: true, + max_line_len: 1_048_576, + }, + decoder: DecoderConfig::Hex { + uppercase: false, + separator: String::from(" "), + bytes_per_group: 1, + endian: Endian::Big, + }, + }], + PipelinePreset::MixedTextPlot => vec![PipelineConfig { + name: pipeline_name.to_string(), + framer: FramerConfig::MixedTextPlot { + strip_cr: true, + max_line_len: 1_048_576, + max_plot_frame: 1_048_576, + }, + decoder: DecoderConfig::MixedTextPlot { + encoding: TextEncoding::Utf8, + }, + }], + PipelinePreset::PlotF32Fixed => vec![PipelineConfig { + name: pipeline_name.to_string(), + framer: FramerConfig::Fixed { + frame_len: plot_frame_len.max(4), + }, + decoder: DecoderConfig::Plot { + sample_type: SampleType::F32, + endian: Endian::Little, + channels: plot_channels.max(1), + format: PlotFormat::Interleaved, + }, + }], + } } -fn build_payload(tab: &SessionTab) -> Result>, String> { - let trimmed = tab.send_input.trim(); - if trimmed.is_empty() { - return Ok(None); +fn infer_pipeline_preset(config: &SessionConfig) -> PipelinePreset { + let has_text = config + .pipelines + .iter() + .any(|pipeline| matches!(pipeline.decoder, DecoderConfig::Text { .. })); + let has_hex = config + .pipelines + .iter() + .any(|pipeline| matches!(pipeline.decoder, DecoderConfig::Hex { .. })); + if has_text && has_hex { + return PipelinePreset::TextHexLines; } - match tab.send_mode { - SendMode::Text => { - let mut text = tab.send_input.clone(); - if tab.append_newline && !text.ends_with('\n') { - text.push('\n'); - } - Ok(Some(text.into_bytes())) - } - SendMode::Hex => { - let compact: String = trimmed - .chars() - .filter(|ch| !ch.is_ascii_whitespace()) - .collect(); - hex::decode(compact) - .map(Some) - .map_err(|err| format!("invalid hex input ({err})")) - } + config + .pipelines + .first() + .map(|pipeline| match pipeline.decoder { + DecoderConfig::Hex { .. } => PipelinePreset::HexLines, + DecoderConfig::Plot { .. } => PipelinePreset::PlotF32Fixed, + DecoderConfig::MixedTextPlot { .. } => PipelinePreset::MixedTextPlot, + DecoderConfig::Text { .. } | DecoderConfig::Lua { .. } => PipelinePreset::TextLines, + }) + .unwrap_or(PipelinePreset::TextHexLines) +} + +fn transport_summary(config: &TransportConfig) -> String { + match config { + TransportConfig::Serial { + port, baud_rate, .. + } => format!("Serial {port} @ {baud_rate}"), + TransportConfig::Tcp { addr } => format!("TCP {addr}"), + TransportConfig::Udp { + bind_addr, + remote_addr, + } => match remote_addr { + Some(remote) => format!("UDP {bind_addr} -> {remote}"), + None => format!("UDP {bind_addr}"), + }, } } @@ -1000,12 +1677,34 @@ fn available_serial_ports() -> Vec { ports } -fn transport_name(transport: TransportType) -> &'static str { - match transport { - TransportType::Serial => "Serial", - TransportType::Tcp => "TCP", - TransportType::Udp => "UDP", - } +fn point_in_rect(x: u16, y: u16, rect: Rect) -> bool { + x >= rect.x + && x < rect.x.saturating_add(rect.width) + && y >= rect.y + && y < rect.y.saturating_add(rect.height) +} + +fn byte_index_for_char(text: &str, char_index: usize) -> usize { + text.char_indices() + .nth(char_index) + .map(|(index, _)| index) + .unwrap_or(text.len()) +} + +fn parse_usize(text: &str, label: &str) -> Result { + text.trim() + .parse::() + .map_err(|_| format!("{label} must be a positive integer")) +} + +fn parse_u32(text: &str, label: &str) -> Result { + text.trim() + .parse::() + .map_err(|_| format!("{label} must be a positive integer")) +} + +fn bool_label(value: bool) -> &'static str { + if value { "on" } else { "off" } } fn serial_data_bits_name(value: SerialDataBits) -> &'static str { @@ -1064,12 +1763,10 @@ fn next_serial_parity(value: SerialParity, forward: bool) -> SerialParity { } } -fn next_serial_stop_bits(value: SerialStopBits, forward: bool) -> SerialStopBits { - match (value, forward) { - (SerialStopBits::One, true) => SerialStopBits::Two, - (SerialStopBits::Two, true) => SerialStopBits::One, - (SerialStopBits::One, false) => SerialStopBits::Two, - (SerialStopBits::Two, false) => SerialStopBits::One, +fn next_serial_stop_bits(value: SerialStopBits, _forward: bool) -> SerialStopBits { + match value { + SerialStopBits::One => SerialStopBits::Two, + SerialStopBits::Two => SerialStopBits::One, } } diff --git a/crates/pipeview-tui/src/app_state.rs b/crates/pipeview-tui/src/app_state.rs index e5769a9..d409086 100644 --- a/crates/pipeview-tui/src/app_state.rs +++ b/crates/pipeview-tui/src/app_state.rs @@ -3,18 +3,20 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +use pipeview_client::SessionConfig; use serde::{Deserialize, Serialize}; use tracing::warn; -use pipeview_client::SessionConfig; + +use crate::app::{DisplayOptions, View, default_session_config}; const TUI_STATE_FILE_NAME: &str = "tui-state.json"; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct PersistedTuiState { + #[serde(default = "default_session")] + pub session: SessionConfig, #[serde(default)] - pub sessions: Vec, - #[serde(default)] - pub active: usize, + pub active_view: PersistedView, #[serde(default = "default_true")] pub show_timestamp: bool, #[serde(default = "default_true")] @@ -23,9 +25,54 @@ pub struct PersistedTuiState { pub show_pipeline: bool, } +impl Default for PersistedTuiState { + fn default() -> Self { + Self { + session: default_session(), + active_view: PersistedView::Text, + show_timestamp: true, + show_direction: true, + show_pipeline: true, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +pub enum PersistedView { + #[default] + Text, + Hex, + Plot, +} + +impl From for PersistedView { + fn from(value: View) -> Self { + match value { + View::Text => Self::Text, + View::Hex => Self::Hex, + View::Plot => Self::Plot, + } + } +} + +impl From for View { + fn from(value: PersistedView) -> Self { + match value { + PersistedView::Text => View::Text, + PersistedView::Hex => View::Hex, + PersistedView::Plot => View::Plot, + } + } +} + pub fn load_tui_state() -> PersistedTuiState { match load_tui_state_from_path(&tui_state_path()) { - Ok(state) => state, + Ok(mut state) => { + if state.session.pipelines.is_empty() { + state.session = default_session(); + } + state + } Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(), Err(err) => { warn!(error = %err, "Failed to load persisted TUI state"); @@ -34,16 +81,35 @@ pub fn load_tui_state() -> PersistedTuiState { } } -pub fn save_tui_state(state: &PersistedTuiState) { - if let Err(err) = save_tui_state_to_path(state, &tui_state_path()) { +pub fn save_tui_state(session: &SessionConfig, view: View, display: DisplayOptions) { + let state = PersistedTuiState { + session: session.clone(), + active_view: view.into(), + show_timestamp: display.show_timestamp, + show_direction: display.show_direction, + show_pipeline: display.show_pipeline, + }; + + if let Err(err) = save_tui_state_to_path(&state, &tui_state_path()) { warn!(error = %err, "Failed to persist TUI state"); } } +fn default_session() -> SessionConfig { + default_session_config() +} + fn tui_state_path() -> PathBuf { state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok()) } +pub fn config_dir() -> PathBuf { + tui_state_path() + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) +} + fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option) -> PathBuf { match os { "windows" => { diff --git a/crates/pipeview-tui/src/buffers.rs b/crates/pipeview-tui/src/buffers.rs index cc66fb1..08a63a4 100644 --- a/crates/pipeview-tui/src/buffers.rs +++ b/crates/pipeview-tui/src/buffers.rs @@ -1,7 +1,9 @@ +use std::collections::VecDeque; use std::time::{Duration, Instant}; use pipeview_client::{DecodedEntry, RingBuffer}; use pipeview_core::protocol::DecodedData; +use pipeview_core::protocol::plot::{PlotFormat, PlotFrame}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum LineDirection { @@ -50,6 +52,10 @@ impl TextBuffer { }); } + pub fn get(&self, index: usize) -> Option<&ConsoleLine> { + self.lines.get(index) + } + pub fn len(&self) -> usize { self.lines.len() } @@ -61,10 +67,6 @@ impl TextBuffer { pub fn set_limit(&mut self, limit: usize) { self.lines.set_limit(limit); } - - pub fn recent(&self, count: usize) -> Vec { - self.lines.drain_recent(count) - } } #[derive(Clone)] @@ -111,6 +113,10 @@ impl HexBuffer { }); } + pub fn get(&self, index: usize) -> Option<&HexLine> { + self.lines.get(index) + } + pub fn len(&self) -> usize { self.lines.len() } @@ -122,9 +128,494 @@ impl HexBuffer { pub fn set_limit(&mut self, limit: usize) { self.lines.set_limit(limit); } +} - pub fn recent(&self, count: usize) -> Vec { - self.lines.drain_recent(count) +pub struct PlotSeries { + pub name: String, + points: VecDeque<[f64; 2]>, + next_x: f64, +} + +impl PlotSeries { + fn new(name: String) -> Self { + Self { + name, + points: VecDeque::new(), + next_x: 0.0, + } + } + + fn push_samples(&mut self, samples: &[f64], limit: usize) { + for sample in samples { + if !sample.is_finite() { + continue; + } + self.push_point([self.next_x, *sample], limit); + self.next_x += 1.0; + } + } + + fn push_point(&mut self, point: [f64; 2], limit: usize) -> Option<[f64; 2]> { + self.points.push_back(point); + if self.points.len() > limit { + self.points.pop_front() + } else { + None + } + } + + pub fn points(&self) -> impl Iterator + '_ { + self.points.iter().copied() + } + + pub fn len(&self) -> usize { + self.points.len() + } + + fn first_point(&self) -> Option<[f64; 2]> { + self.points.front().copied() + } + + fn last_point(&self) -> Option<[f64; 2]> { + self.points.back().copied() + } + + pub fn render_points_time_series( + &self, + x_min: f64, + x_max: f64, + max_points: usize, + ) -> Vec<[f64; 2]> { + if self.points.is_empty() || max_points == 0 { + return Vec::new(); + } + + let mut exact_visible = Vec::with_capacity(max_points.min(self.points.len())); + for point in self.points.iter().copied() { + if point[0] < x_min { + continue; + } + if point[0] > x_max { + break; + } + exact_visible.push(point); + if exact_visible.len() > max_points { + exact_visible.clear(); + break; + } + } + if !exact_visible.is_empty() { + return exact_visible; + } + + let bucket_count = (max_points / 2).max(1); + let width = (x_max - x_min).max(1.0); + let bucket_width = width / bucket_count as f64; + let mut rendered = Vec::with_capacity(bucket_count * 2); + let mut points = self + .points + .iter() + .copied() + .skip_while(|point| point[0] < x_min) + .peekable(); + + for bucket_index in 0..bucket_count { + let bucket_start = x_min + bucket_width * bucket_index as f64; + let bucket_end = if bucket_index + 1 == bucket_count { + x_max + } else { + bucket_start + bucket_width + }; + + let mut min_point: Option<[f64; 2]> = None; + let mut max_point: Option<[f64; 2]> = None; + + while let Some(point) = points.peek().copied() { + if point[0] > bucket_end { + break; + } + if point[0] >= bucket_start { + match min_point { + Some(current) if current[1] <= point[1] => {} + _ => min_point = Some(point), + } + match max_point { + Some(current) if current[1] >= point[1] => {} + _ => max_point = Some(point), + } + } + points.next(); + } + + match (min_point, max_point) { + (Some(a), Some(b)) if a[0] <= b[0] => { + rendered.push(a); + if a != b { + rendered.push(b); + } + } + (Some(a), Some(b)) => { + rendered.push(b); + if a != b { + rendered.push(a); + } + } + (Some(a), None) | (None, Some(a)) => rendered.push(a), + (None, None) => {} + } + } + + if rendered.len() > max_points { + let stride = rendered.len().div_ceil(max_points); + rendered.into_iter().step_by(stride).collect() + } else { + rendered + } + } + + pub fn render_points_xy(&self, max_points: usize) -> Vec<[f64; 2]> { + if self.points.is_empty() || max_points == 0 { + return Vec::new(); + } + if self.points.len() <= max_points { + return self.points.iter().copied().collect(); + } + + let stride = self.points.len().div_ceil(max_points); + self.points.iter().copied().step_by(stride).collect() + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PlotSeriesKind { + TimeSeries, + XY, +} + +#[derive(Clone, Copy)] +struct BoundsRect { + min_x: f64, + max_x: f64, + min_y: f64, + max_y: f64, +} + +impl BoundsRect { + fn from_point([x, y]: [f64; 2]) -> Option { + if !(x.is_finite() && y.is_finite()) { + return None; + } + Some(Self { + min_x: x, + max_x: x, + min_y: y, + max_y: y, + }) + } + + fn extend_with_point(&mut self, [x, y]: [f64; 2]) { + if !(x.is_finite() && y.is_finite()) { + return; + } + self.min_x = self.min_x.min(x); + self.max_x = self.max_x.max(x); + self.min_y = self.min_y.min(y); + self.max_y = self.max_y.max(y); + } + + fn touches(&self, [x, y]: [f64; 2]) -> bool { + x == self.min_x || x == self.max_x || y == self.min_y || y == self.max_y + } +} + +pub struct PlotBuffer { + limit: usize, + kind: PlotSeriesKind, + series: Vec, + time_series_y_bounds: Option<(f64, f64)>, + time_series_y_dirty: bool, + xy_bounds: Option, + xy_bounds_dirty: bool, +} + +impl PlotBuffer { + pub fn new(limit: usize) -> Self { + Self { + limit, + kind: PlotSeriesKind::TimeSeries, + series: Vec::new(), + time_series_y_bounds: None, + time_series_y_dirty: false, + xy_bounds: None, + xy_bounds_dirty: false, + } + } + + pub fn push(&mut self, entry: &DecodedEntry) { + if let DecodedData::Plot(frame) = &entry.data { + self.push_frame(&entry.pipeline_name, frame); + } + } + + fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) { + let next_kind = match frame.format { + PlotFormat::XY => PlotSeriesKind::XY, + PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries, + }; + if self.kind != next_kind { + self.invalidate_bounds(); + } + self.kind = next_kind; + + if matches!(frame.format, PlotFormat::XY) { + self.push_xy_frame(pipeline_name, frame); + return; + } + + for (index, channel) in frame.channels.iter().enumerate() { + let series_name = if frame.channels.len() == 1 { + pipeline_name.to_owned() + } else { + format!("{pipeline_name}:ch{}", index + 1) + }; + + if let Some(series_index) = self + .series + .iter() + .position(|series| series.name == series_name) + { + for sample in channel { + if !sample.is_finite() { + continue; + } + let (point, removed) = { + let series = &mut self.series[series_index]; + let point = [series.next_x, *sample]; + let removed = series.push_point(point, self.limit); + series.next_x += 1.0; + (point, removed) + }; + if let Some(removed) = removed { + self.note_time_series_removed(removed); + } + self.note_time_series_point(point); + } + } else { + let mut series = PlotSeries::new(series_name); + series.push_samples(channel, self.limit); + for point in series.points() { + self.note_time_series_point(point); + } + self.series.push(series); + } + } + } + + fn push_xy_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) { + if frame.channels.len() < 2 { + return; + } + + let x = &frame.channels[0]; + let y = &frame.channels[1]; + let len = x.len().min(y.len()); + let series_name = format!("{pipeline_name}:xy"); + + let series_index = if let Some(index) = self + .series + .iter() + .position(|series| series.name == series_name) + { + index + } else { + self.series.push(PlotSeries::new(series_name)); + self.series.len() - 1 + }; + + for index in 0..len { + if !x[index].is_finite() || !y[index].is_finite() { + continue; + } + let point = [x[index], y[index]]; + let removed = { + let series = &mut self.series[series_index]; + series.push_point(point, self.limit) + }; + if let Some(removed) = removed { + self.note_xy_removed(removed); + } + self.note_xy_point(point); + } + } + + pub fn clear(&mut self) { + self.series.clear(); + self.invalidate_bounds(); + } + + pub fn set_limit(&mut self, limit: usize) { + self.limit = limit; + for series in &mut self.series { + while series.points.len() > self.limit { + series.points.pop_front(); + } + } + self.invalidate_bounds(); + } + + pub fn is_empty(&self) -> bool { + self.series.is_empty() + } + + pub fn kind(&self) -> PlotSeriesKind { + self.kind + } + + pub fn iter(&self) -> impl Iterator { + self.series.iter() + } + + pub fn series_len(&self) -> usize { + self.series.len() + } + + pub fn total_points(&self) -> usize { + self.series.iter().map(PlotSeries::len).sum() + } + + pub fn bounds(&mut self) -> Option<([f64; 2], [f64; 2])> { + match self.kind { + PlotSeriesKind::TimeSeries => self.time_series_bounds(), + PlotSeriesKind::XY => self.xy_bounds(), + } + } + + fn invalidate_bounds(&mut self) { + self.time_series_y_bounds = None; + self.time_series_y_dirty = false; + self.xy_bounds = None; + self.xy_bounds_dirty = false; + } + + fn note_time_series_point(&mut self, point: [f64; 2]) { + if !point[1].is_finite() { + return; + } + match &mut self.time_series_y_bounds { + Some((min_y, max_y)) => { + *min_y = min_y.min(point[1]); + *max_y = max_y.max(point[1]); + } + None => self.time_series_y_bounds = Some((point[1], point[1])), + } + } + + fn note_time_series_removed(&mut self, removed: [f64; 2]) { + if let Some((min_y, max_y)) = self.time_series_y_bounds + && (removed[1] == min_y || removed[1] == max_y) + { + self.time_series_y_dirty = true; + } + } + + fn note_xy_point(&mut self, point: [f64; 2]) { + match &mut self.xy_bounds { + Some(bounds) => bounds.extend_with_point(point), + None => self.xy_bounds = BoundsRect::from_point(point), + } + } + + fn note_xy_removed(&mut self, removed: [f64; 2]) { + if let Some(bounds) = self.xy_bounds + && bounds.touches(removed) + { + self.xy_bounds_dirty = true; + } + } + + fn time_series_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> { + let mut min_x = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + + for series in &self.series { + if let Some([x, _]) = series.first_point() { + min_x = min_x.min(x); + } + if let Some([x, _]) = series.last_point() { + max_x = max_x.max(x); + } + } + + if !(min_x.is_finite() && max_x.is_finite()) { + return None; + } + + if self.time_series_y_dirty { + self.recompute_time_series_y_bounds(); + } + let (mut min_y, mut max_y) = self.time_series_y_bounds?; + if min_y == max_y { + min_y -= 1.0; + max_y += 1.0; + } + Some(([min_x, min_y], [max_x, max_y])) + } + + fn xy_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> { + if self.xy_bounds_dirty { + self.recompute_xy_bounds(); + } + let bounds = self.xy_bounds?; + let mut min_x = bounds.min_x; + let mut max_x = bounds.max_x; + let mut min_y = bounds.min_y; + let mut max_y = bounds.max_y; + if min_x == max_x { + min_x -= 1.0; + max_x += 1.0; + } + if min_y == max_y { + min_y -= 1.0; + max_y += 1.0; + } + Some(([min_x, min_y], [max_x, max_y])) + } + + fn recompute_time_series_y_bounds(&mut self) { + let mut min_y = f64::INFINITY; + let mut max_y = f64::NEG_INFINITY; + + for series in &self.series { + for [_, y] in series.points() { + if y.is_finite() { + min_y = min_y.min(y); + max_y = max_y.max(y); + } + } + } + + self.time_series_y_bounds = if min_y.is_finite() && max_y.is_finite() { + Some((min_y, max_y)) + } else { + None + }; + self.time_series_y_dirty = false; + } + + fn recompute_xy_bounds(&mut self) { + let mut bounds: Option = None; + + for series in &self.series { + for point in series.points() { + match &mut bounds { + Some(existing) => existing.extend_with_point(point), + None => bounds = BoundsRect::from_point(point), + } + } + } + + self.xy_bounds = bounds; + self.xy_bounds_dirty = false; } } diff --git a/crates/pipeview-tui/src/main.rs b/crates/pipeview-tui/src/main.rs index 33f19f1..137aacd 100644 --- a/crates/pipeview-tui/src/main.rs +++ b/crates/pipeview-tui/src/main.rs @@ -1,3 +1,4 @@ +mod ansi; mod app; mod app_state; mod buffers; @@ -6,33 +7,59 @@ mod ui; use std::io; use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{Terminal, backend::CrosstermBackend}; -use pipeview_client::SessionManager; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::{EnvFilter, fmt}; + +use crate::app::App; #[tokio::main] async fn main() -> io::Result<()> { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); + let _log_guard = init_tracing(); enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let manager = SessionManager::new(); - let rx = manager.subscribe(); - - let result = app::run(&mut terminal, app::App::new(manager, rx)); + let result = async { + let mut app = App::new(); + app.run(&mut terminal).await?; + app.shutdown().await; + io::Result::Ok(()) + } + .await; disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; terminal.show_cursor()?; result } + +fn init_tracing() -> Option { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("pipeview_tui=info,pipeview_client=info")); + let log_dir = app_state::config_dir(); + let _ = std::fs::create_dir_all(&log_dir); + let file_appender = tracing_appender::rolling::never(log_dir, "tui.log"); + let (writer, guard) = tracing_appender::non_blocking(file_appender); + + fmt() + .with_env_filter(filter) + .with_target(false) + .with_ansi(false) + .with_writer(writer) + .try_init() + .ok() + .map(|_| guard) +} diff --git a/crates/pipeview-tui/src/ui.rs b/crates/pipeview-tui/src/ui.rs index 00bf81e..4037ad9 100644 --- a/crates/pipeview-tui/src/ui.rs +++ b/crates/pipeview-tui/src/ui.rs @@ -1,314 +1,925 @@ +use std::time::Duration; + use ratatui::{ Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Modifier, Style}, - widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + symbols::Marker, + text::{Line, Span}, + widgets::{ + Axis, Block, Borders, Chart, Clear, Dataset, GraphType, List, ListItem, Paragraph, Wrap, + }, }; -use crate::app::{App, AppMode, ConnectionStatus, DisplayOptions, SendMode, SessionForm, View}; -use crate::buffers::{ConsoleLine, HexLine, LineDirection}; +use crate::ansi::ansi_to_spans; +use crate::app::{ + App, AppMode, ConfigField, ConnectionStatus, DisplayOptions, FocusPane, HotAction, LineEnding, + SendMode, View, +}; +use crate::buffers::{ConsoleLine, HexLine, LineDirection, PlotSeriesKind}; -const MAX_RENDER_LINES: usize = 400; +const BG: Color = Color::Rgb(8, 12, 18); +const PANEL: Color = Color::Rgb(12, 20, 28); +const PANEL_ALT: Color = Color::Rgb(17, 27, 38); +const CYAN: Color = Color::Rgb(55, 214, 230); +const GREEN: Color = Color::Rgb(72, 211, 137); +const AMBER: Color = Color::Rgb(244, 184, 74); +const MAGENTA: Color = Color::Rgb(219, 111, 220); +const RED: Color = Color::Rgb(238, 92, 107); +const BLUE: Color = Color::Rgb(96, 165, 250); +const MUTED: Color = Color::Rgb(120, 134, 153); +const TEXT: Color = Color::Rgb(224, 234, 244); + +pub fn render(frame: &mut Frame, app: &mut App) { + app.reset_hot_zones(); + let area = frame.area(); + frame.render_widget(Block::default().style(Style::default().bg(BG)), area); -pub fn render(frame: &mut Frame, app: &App) { let root = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(34), Constraint::Min(20)]) - .split(frame.area()); - - render_sidebar(frame, app, root[0]); - render_main(frame, app, root[1]); - - if let AppMode::SessionForm(form) = app.mode() { - render_session_form(frame, form); - } -} - -fn render_sidebar(frame: &mut Frame, app: &App, area: Rect) { - let items: Vec = app - .tabs() - .iter() - .enumerate() - .map(|(index, tab)| { - let prefix = if index == app.active_index() { - "> " - } else { - " " - }; - let transport = App::transport_summary(&tab.session_config); - ListItem::new(format!( - "{prefix}[{}] {}\n {}", - tab.id, - tab.status.badge(), - transport - )) - }) - .collect(); - - let sidebar = if items.is_empty() { - Paragraph::new("No sessions.\nPress n to create one.") - .block(Block::default().borders(Borders::ALL).title("Sessions")) - .wrap(Wrap { trim: false }) - } else { - Paragraph::new("").block(Block::default().borders(Borders::ALL).title("Sessions")) - }; - - frame.render_widget(sidebar, area); - if !items.is_empty() { - frame.render_widget( - List::new(items).block(Block::default().borders(Borders::ALL).title("Sessions")), - area, - ); - } -} - -fn render_main(frame: &mut Frame, app: &App, area: Rect) { - let sections = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(4), - Constraint::Min(8), - Constraint::Length(7), - Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(6), + Constraint::Length(2), ]) .split(area); - if let Some(tab) = app.active_tab() { - render_header(frame, app, sections[0]); - render_receive(frame, app, sections[1]); - render_send(frame, app, sections[2]); - let footer = format!( - "{}{}", - app.help_text(), - app.notice() - .map(|notice| format!(" | {notice}")) - .unwrap_or_default() - ); - frame.render_widget( - Paragraph::new(footer) - .block(Block::default().borders(Borders::ALL).title("Help")) - .wrap(Wrap { trim: false }), - sections[3], - ); + render_header(frame, app, root[0]); - if let ConnectionStatus::Error(_) = &tab.status { - } - } else { - frame.render_widget( - Paragraph::new(format!( - "No active session.\n{}\n{}", - app.help_text(), - app.notice().unwrap_or("") - )) - .block(Block::default().borders(Borders::ALL).title("pipeview-tui")) - .wrap(Wrap { trim: false }), - area, - ); + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(34), Constraint::Min(30)]) + .split(root[1]); + render_controls(frame, app, body[0]); + render_main(frame, app, body[1]); + render_composer(frame, app, root[2]); + render_footer(frame, app, root[3]); + + match app.mode { + AppMode::Search => render_search_modal(frame, app), + AppMode::Config => render_config_modal(frame, app), + AppMode::Help => render_help_modal(frame, app), + AppMode::Normal | AppMode::EditingSend => {} } } fn render_header(frame: &mut Frame, app: &App, area: Rect) { - let tab = app.active_tab().expect("active tab"); - let display = app.display(); - let status_line = match &tab.status { - ConnectionStatus::Error(message) => { - format!("Session {} [{}] {}", tab.id, tab.status.badge(), message) - } - _ => format!("Session {} [{}]", tab.id, tab.status.badge()), + let status = &app.session.status; + let status_color = status_color(status); + let error = match status { + ConnectionStatus::Error(message) => format!(" {message}"), + _ => String::new(), }; - let line2 = format!( - "{} | View: {} | Send: {} | AutoReconnect: {} | AppendNewline: {}", - App::transport_summary(&tab.session_config), - match tab.view { - View::Text => "Text", - View::Hex => "Hex", - }, - match tab.send_mode { - SendMode::Text => "Text", - SendMode::Hex => "Hex", - }, - tab.auto_reconnect, - tab.append_newline - ); - let line3 = format!( - "Display: ts={} dir={} pipe={} | Text lines={} Hex lines={}", - display.show_timestamp, - display.show_direction, - display.show_pipeline, - tab.console.len(), - tab.hex.len() - ); + + let header = vec![ + Line::from(vec![ + Span::styled( + " PIPEVIEW ", + Style::default() + .fg(Color::Black) + .bg(CYAN) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "single session telemetry console", + Style::default().fg(TEXT).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + status.label(), + Style::default() + .fg(Color::Black) + .bg(status_color) + .add_modifier(Modifier::BOLD), + ), + Span::styled(error, Style::default().fg(RED)), + ]), + Line::from(vec![ + Span::styled(app.transport_summary(), Style::default().fg(AMBER)), + Span::raw(" | pipelines: "), + Span::styled(app.pipeline_summary(), Style::default().fg(MAGENTA)), + Span::raw(" | view: "), + Span::styled( + app.session.view.label(), + Style::default().fg(view_color(app.session.view)), + ), + Span::raw(" | rx/tx: "), + Span::styled( + format!( + "{}/{}", + app.session.received_messages, app.session.sent_messages + ), + Style::default().fg(GREEN), + ), + ]), + ]; + frame.render_widget( - Paragraph::new(format!("{status_line}\n{line2}\n{line3}")) - .block(Block::default().borders(Borders::ALL).title("Session")) + Paragraph::new(header) + .block(panel_block("Status", false, CYAN)) .wrap(Wrap { trim: false }), area, ); } -fn render_receive(frame: &mut Frame, app: &App, area: Rect) { - let tab = app.active_tab().expect("active tab"); - let display = app.display(); - let title = match tab.view { - View::Text => "Receive Text", - View::Hex => "Receive Hex", - }; +fn render_controls(frame: &mut Frame, app: &mut App, area: Rect) { + app.add_hot_zone(area, HotAction::Focus(FocusPane::Controls)); + let focused = app.focus == FocusPane::Controls; + let block = panel_block("Control Deck", focused, AMBER); + let inner = block.inner(area); + frame.render_widget(block, area); - let lines: Vec = match tab.view { - View::Text => tab - .console - .recent(MAX_RENDER_LINES) - .into_iter() - .map(|line| format_console_line(&line, display)) - .collect(), - View::Hex => tab - .hex - .recent(MAX_RENDER_LINES) - .into_iter() - .map(|line| format_hex_line(&line, display)) - .collect(), - }; - - let body = if lines.is_empty() { - String::from("no data") + let connect_label = if app.session.status.is_connectedish() { + "[ Disconnect ]" } else { - lines.join("\n") + "[ Connect ]" }; + let ending = next_line_ending(app.session.line_ending); + let rows: Vec<(Line<'static>, Option)> = vec![ + section_line("SESSION"), + button_line( + connect_label, + status_color(&app.session.status), + Some(HotAction::ToggleConnection), + ), + button_line("[ Reconnect ]", BLUE, Some(HotAction::Reconnect)), + button_line("[ Clear Buffers ]", RED, Some(HotAction::Clear)), + button_line("[ Configure ]", MAGENTA, Some(HotAction::OpenConfig)), + spacer_line(), + section_line("LINES"), + toggle_line( + "Auto reconnect", + app.session.auto_reconnect, + Some(HotAction::ToggleAutoReconnect), + ), + toggle_line("DTR", app.session.dtr, Some(HotAction::ToggleDtr)), + toggle_line("RTS", app.session.rts, Some(HotAction::ToggleRts)), + spacer_line(), + section_line("DISPLAY"), + toggle_line( + "Timestamp", + app.display.show_timestamp, + Some(HotAction::ToggleTimestamp), + ), + toggle_line( + "Direction", + app.display.show_direction, + Some(HotAction::ToggleDirection), + ), + toggle_line( + "Pipeline", + app.display.show_pipeline, + Some(HotAction::TogglePipeline), + ), + spacer_line(), + section_line("SEND"), + value_line( + "Mode", + app.session.send_mode.label(), + Some(HotAction::SendMode(match app.session.send_mode { + SendMode::Text => SendMode::Hex, + SendMode::Hex => SendMode::Text, + })), + ), + value_line( + "Ending", + app.session.line_ending.label(), + Some(HotAction::LineEnding(ending)), + ), + button_line("[ Search ]", CYAN, Some(HotAction::OpenSearch)), + button_line("[ Help ]", MUTED, Some(HotAction::OpenHelp)), + ]; - frame.render_widget( - Paragraph::new(body) - .block(Block::default().borders(Borders::ALL).title(title)) - .wrap(Wrap { trim: false }), - area, - ); -} - -fn render_send(frame: &mut Frame, app: &App, area: Rect) { - let tab = app.active_tab().expect("active tab"); - let editing = matches!(app.mode(), AppMode::SendInput); - let title = if editing { "Send [editing]" } else { "Send" }; - let status = tab.send_status.as_deref().unwrap_or("idle"); - let hint = match tab.send_mode { - SendMode::Text => "Press i to edit input, Enter to send while editing", - SendMode::Hex => "Hex bytes, e.g. 48 65 6C 6C 6F", - }; - let body = format!( - "Mode: {} | Append newline: {}\nStatus: {}\nHint: {}\n\n{}", - match tab.send_mode { - SendMode::Text => "Text", - SendMode::Hex => "Hex", - }, - tab.append_newline, - status, - hint, - if tab.send_input.is_empty() { - String::from("") - } else { - tab.send_input.clone() + for (index, (_, action)) in rows.iter().enumerate() { + if let Some(action) = action { + let y = inner.y.saturating_add(index as u16); + if y < inner.y.saturating_add(inner.height) { + app.add_hot_zone( + Rect { + x: inner.x, + y, + width: inner.width, + height: 1, + }, + *action, + ); + } } - ); + } frame.render_widget( - Paragraph::new(body) - .block(Block::default().borders(Borders::ALL).title(title)) - .wrap(Wrap { trim: false }), - area, + Paragraph::new(rows.into_iter().map(|(line, _)| line).collect::>()) + .style(Style::default().fg(TEXT).bg(PANEL)), + inner, ); } -fn render_session_form(frame: &mut Frame, form: &SessionForm) { - let rows = form.rows(); - let footer_lines = form.footer_lines(); - let height = (rows.len() + footer_lines.len() + 4) as u16; - let area = centered_rect(82, height, frame.area()); - let inner = Layout::default() +fn render_main(frame: &mut Frame, app: &mut App, area: Rect) { + app.add_hot_zone(area, HotAction::Focus(FocusPane::Main)); + let layout = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(1), - Constraint::Length(footer_lines.len() as u16), - ]) - .margin(1) + .constraints([Constraint::Length(3), Constraint::Min(5)]) .split(area); - let items: Vec = rows - .into_iter() - .enumerate() - .map(|(idx, row)| ListItem::new(row).style(focus_style(idx == form.focused_field))) - .collect(); + render_tabs(frame, app, layout[0]); + match app.session.view { + View::Text => render_text_view(frame, app, layout[1]), + View::Hex => render_hex_view(frame, app, layout[1]), + View::Plot => render_plot_view(frame, app, layout[1]), + } +} + +fn render_tabs(frame: &mut Frame, app: &mut App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(34), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]) + .split(area); + + for (idx, view) in View::ALL.into_iter().enumerate() { + let selected = app.session.view == view; + app.add_hot_zone(chunks[idx], HotAction::View(view)); + let color = view_color(view); + let block = panel_block(view.label(), selected, color); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + view.label(), + Style::default() + .fg(if selected { Color::Black } else { color }) + .bg(if selected { color } else { PANEL }) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + match view { + View::Text => format!(" {} lines", app.session.console.len()), + View::Hex => format!(" {} lines", app.session.hex.len()), + View::Plot => format!( + " {} series / {} pts", + app.session.plot.series_len(), + app.session.plot.total_points() + ), + }, + Style::default().fg(MUTED), + ), + ])) + .block(block) + .alignment(Alignment::Center), + chunks[idx], + ); + } +} + +fn render_text_view(frame: &mut Frame, app: &mut App, area: Rect) { + let focused = app.focus == FocusPane::Main; + let block = panel_block("Text Stream", focused, CYAN); + let inner = block.inner(area); + let height = inner.height as usize; + let total = app.session.console.len(); + let start = app.visible_start(total, height); + let end = start.saturating_add(height).min(total); + + let lines = if total == 0 { + vec![Line::styled("no text data", Style::default().fg(MUTED))] + } else { + (start..end) + .filter_map(|index| { + app.session + .console + .get(index) + .map(|line| format_console_line(index, line, app.display, app)) + }) + .collect() + }; - frame.render_widget(Clear, area); frame.render_widget( - Block::default().borders(Borders::ALL).title(form.title()), + Paragraph::new(lines) + .block(block) + .style(Style::default().bg(PANEL_ALT)) + .wrap(Wrap { trim: false }), area, ); - frame.render_widget(List::new(items), inner[0]); - frame.render_widget(Paragraph::new(footer_lines.join("\n")), inner[1]); } -fn format_console_line(line: &ConsoleLine, display: DisplayOptions) -> String { - let mut prefix = Vec::new(); +fn render_hex_view(frame: &mut Frame, app: &mut App, area: Rect) { + let focused = app.focus == FocusPane::Main; + let block = panel_block("Hex Stream", focused, AMBER); + let inner = block.inner(area); + let height = inner.height as usize; + let total = app.session.hex.len(); + let start = app.visible_start(total, height); + let end = start.saturating_add(height).min(total); + + let lines = if total == 0 { + vec![Line::styled("no hex data", Style::default().fg(MUTED))] + } else { + (start..end) + .filter_map(|index| { + app.session + .hex + .get(index) + .map(|line| format_hex_line(index, line, app.display, app)) + }) + .collect() + }; + + frame.render_widget( + Paragraph::new(lines) + .block(block) + .style(Style::default().bg(PANEL_ALT)) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn render_plot_view(frame: &mut Frame, app: &mut App, area: Rect) { + let focused = app.focus == FocusPane::Main; + let block = panel_block("Plot View", focused, GREEN); + let inner = block.inner(area); + app.add_hot_zone(inner, HotAction::Focus(FocusPane::Main)); + + if app.session.plot.is_empty() { + frame.render_widget( + Paragraph::new(vec![ + Line::styled("no plot data", Style::default().fg(MUTED)), + Line::from(vec![ + Span::raw("pipeline: "), + Span::styled(app.pipeline_summary(), Style::default().fg(MAGENTA)), + ]), + ]) + .block(block) + .style(Style::default().bg(PANEL_ALT)) + .alignment(Alignment::Center), + area, + ); + return; + } + + let Some((min, max)) = app.plot_bounds() else { + frame.render_widget( + Paragraph::new("plot bounds unavailable") + .block(block) + .style(Style::default().fg(MUTED).bg(PANEL_ALT)), + area, + ); + return; + }; + + let max_points = (inner.width as usize).saturating_mul(2).max(32); + let kind = app.session.plot.kind(); + let series_points: Vec<(String, Vec<(f64, f64)>)> = app + .session + .plot + .iter() + .map(|series| { + let points = match kind { + PlotSeriesKind::TimeSeries => { + series.render_points_time_series(min[0], max[0], max_points) + } + PlotSeriesKind::XY => series.render_points_xy(max_points), + }; + ( + series.name.clone(), + points.into_iter().map(|[x, y]| (x, y)).collect(), + ) + }) + .collect(); + + let palette = [GREEN, CYAN, AMBER, MAGENTA, BLUE, RED]; + let datasets = series_points + .iter() + .enumerate() + .map(|(index, (name, points))| { + Dataset::default() + .name(name.as_str()) + .marker(Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(palette[index % palette.len()])) + .data(points) + }) + .collect::>(); + + let chart = Chart::new(datasets) + .block(block) + .style(Style::default().bg(PANEL_ALT)) + .x_axis(axis("X", min[0], max[0], CYAN)) + .y_axis(axis("Y", min[1], max[1], GREEN)); + + frame.render_widget(chart, area); + + let overlay = Rect { + x: inner.x.saturating_add(1), + y: inner.y, + width: inner.width.saturating_sub(2).min(72), + height: 1, + }; + let controls = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(16), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Min(1), + ]) + .split(overlay); + app.add_hot_zone(controls[0], HotAction::PlotFollow); + app.add_hot_zone(controls[1], HotAction::PlotZoomIn); + app.add_hot_zone(controls[2], HotAction::PlotZoomOut); + let mode = match kind { + PlotSeriesKind::TimeSeries => "time", + PlotSeriesKind::XY => "xy", + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + format!( + "[follow {}] ", + if app.plot.follow_latest { "on" } else { "off" } + ), + Style::default() + .fg(Color::Black) + .bg(if app.plot.follow_latest { GREEN } else { MUTED }) + .add_modifier(Modifier::BOLD), + ), + Span::styled("[zoom +] ", Style::default().fg(Color::Black).bg(CYAN)), + Span::styled("[zoom -] ", Style::default().fg(Color::Black).bg(AMBER)), + Span::styled(format!("{mode} "), Style::default().fg(MUTED)), + Span::styled( + format!( + "{} series / {} pts", + app.session.plot.series_len(), + app.session.plot.total_points() + ), + Style::default().fg(TEXT), + ), + ])) + .style(Style::default().bg(PANEL_ALT)), + overlay, + ); +} + +fn render_composer(frame: &mut Frame, app: &mut App, area: Rect) { + app.add_hot_zone(area, HotAction::Focus(FocusPane::Composer)); + let focused = app.focus == FocusPane::Composer || matches!(app.mode, AppMode::EditingSend); + let block = panel_block("Composer", focused, MAGENTA); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(20), Constraint::Length(28)]) + .split(inner); + + let input_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if focused { MAGENTA } else { MUTED })) + .style(Style::default().bg(PANEL_ALT)); + + frame.render_widget( + Paragraph::new(input_line(app)) + .block(input_block) + .wrap(Wrap { trim: false }), + chunks[0], + ); + + let side_rows = vec![ + value_line( + "Mode", + app.session.send_mode.label(), + Some(HotAction::SendMode(match app.session.send_mode { + SendMode::Text => SendMode::Hex, + SendMode::Hex => SendMode::Text, + })), + ), + value_line( + "Ending", + app.session.line_ending.label(), + Some(HotAction::LineEnding(next_line_ending( + app.session.line_ending, + ))), + ), + button_line("[ Send ]", GREEN, Some(HotAction::Send)), + ( + Line::from(vec![ + Span::styled("Status ", Style::default().fg(MUTED)), + Span::styled(app.session.send_status.clone(), Style::default().fg(TEXT)), + ]), + None, + ), + ]; + + for (index, (_, action)) in side_rows.iter().enumerate() { + if let Some(action) = action { + app.add_hot_zone( + Rect { + x: chunks[1].x, + y: chunks[1].y.saturating_add(index as u16), + width: chunks[1].width, + height: 1, + }, + *action, + ); + } + } + + frame.render_widget( + Paragraph::new( + side_rows + .into_iter() + .map(|(line, _)| line) + .collect::>(), + ) + .style(Style::default().bg(PANEL)), + chunks[1], + ); +} + +fn render_footer(frame: &mut Frame, app: &App, area: Rect) { + let search = if app.search.active && !app.search.query.is_empty() { + format!( + " search {}/{} '{}'", + app.search.display_index(), + app.search.count(), + app.search.query + ) + } else { + String::new() + }; + let shortcuts = "1/2/3 view c connect r reconnect e config / search Ctrl-S send q quit"; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + format!(" {} ", app.notice), + Style::default().fg(TEXT).bg(PANEL_ALT), + ), + Span::styled(search, Style::default().fg(AMBER).bg(PANEL_ALT)), + Span::styled(" ", Style::default().bg(PANEL_ALT)), + Span::styled(shortcuts, Style::default().fg(MUTED).bg(PANEL_ALT)), + ])), + area, + ); +} + +fn render_search_modal(frame: &mut Frame, app: &mut App) { + let area = centered_rect(70, 7, frame.area()); + app.add_hot_zone(area, HotAction::CloseModal); + frame.render_widget(Clear, area); + let block = panel_block("Search", true, CYAN); + let inner = block.inner(area); + frame.render_widget(block, area); + + let body = vec![ + Line::from(vec![ + Span::styled("Query: ", Style::default().fg(MUTED)), + Span::styled(app.search.query.clone(), Style::default().fg(TEXT)), + Span::styled( + "_", + Style::default().fg(CYAN).add_modifier(Modifier::SLOW_BLINK), + ), + ]), + Line::from(vec![ + Span::styled("Matches: ", Style::default().fg(MUTED)), + Span::styled( + format!("{}/{}", app.search.display_index(), app.search.count()), + Style::default().fg(AMBER), + ), + Span::raw(" "), + Span::styled( + if app.search.case_sensitive { + "case sensitive" + } else { + "case insensitive" + }, + Style::default().fg(MAGENTA), + ), + ]), + Line::styled( + "Enter close Up/Down navigate Ctrl-C case", + Style::default().fg(MUTED), + ), + ]; + + frame.render_widget( + Paragraph::new(body) + .style(Style::default().bg(PANEL)) + .wrap(Wrap { trim: false }), + inner, + ); +} + +fn render_config_modal(frame: &mut Frame, app: &mut App) { + let rows = app.config_form.rows(); + let height = (rows.len() as u16 + 6).min(frame.area().height.saturating_sub(2)); + let area = centered_rect(88, height, frame.area()); + frame.render_widget(Clear, area); + let block = panel_block("Session Config", true, MAGENTA); + let inner = block.inner(area); + frame.render_widget(block, area); + + let footer_height = 3; + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(footer_height)]) + .split(inner); + + let items = rows + .iter() + .enumerate() + .map(|(index, row)| { + let focused = index == app.config_form.focused; + let value_style = if row.editable { + Style::default().fg(CYAN) + } else { + Style::default().fg(AMBER) + }; + ListItem::new(Line::from(vec![ + Span::styled( + format!("{:<18}", row.label), + Style::default().fg(if focused { Color::Black } else { MUTED }), + ), + Span::styled( + row.value.clone(), + if focused { + value_style.bg(MAGENTA).fg(Color::Black) + } else { + value_style + }, + ), + ])) + .style(if focused { + Style::default().bg(MAGENTA) + } else { + Style::default().bg(PANEL) + }) + }) + .collect::>(); + + for (index, row) in rows.iter().enumerate() { + let y = layout[0].y.saturating_add(index as u16); + if y >= layout[0].y.saturating_add(layout[0].height) { + break; + } + app.add_hot_zone( + Rect { + x: layout[0].x, + y, + width: layout[0].width, + height: 1, + }, + if row.field == ConfigField::Apply { + HotAction::ConfigSubmit + } else { + HotAction::ConfigFocus(row.field) + }, + ); + } + + frame.render_widget( + List::new(items).style(Style::default().bg(PANEL)), + layout[0], + ); + app.add_hot_zone(layout[1], HotAction::ConfigCancel); + frame.render_widget( + Paragraph::new(vec![ + Line::styled( + "Tab move Left/Right change options Enter apply Esc cancel", + Style::default().fg(MUTED), + ), + Line::styled(serial_ports_hint(app), Style::default().fg(BLUE)), + ]) + .style(Style::default().bg(PANEL)), + layout[1], + ); +} + +fn render_help_modal(frame: &mut Frame, app: &mut App) { + let area = centered_rect(74, 12, frame.area()); + app.add_hot_zone(area, HotAction::CloseModal); + frame.render_widget(Clear, area); + let block = panel_block("Help", true, BLUE); + let inner = block.inner(area); + frame.render_widget(block, area); + frame.render_widget( + Paragraph::new(vec![ + Line::styled( + "Keyboard", + Style::default().fg(CYAN).add_modifier(Modifier::BOLD), + ), + Line::raw("1/2/3 switch views, Tab shift focus, q quit"), + Line::raw("c connect, r reconnect, e config, / search, x clear"), + Line::raw("Ctrl-S send, Enter edit/send composer, Esc close modal"), + Line::styled( + "Mouse", + Style::default().fg(AMBER).add_modifier(Modifier::BOLD), + ), + Line::raw("Click tabs, buttons, toggles, config rows, and composer."), + Line::raw("Wheel scrolls Text/Hex and zooms Plot."), + ]) + .style(Style::default().fg(TEXT).bg(PANEL)) + .wrap(Wrap { trim: false }), + inner, + ); +} + +fn format_console_line( + index: usize, + line: &ConsoleLine, + display: DisplayOptions, + app: &App, +) -> Line<'static> { + let mut spans = prefix_spans(line.elapsed, line.direction, &line.pipeline, display); + let base_style = Style::default().fg(TEXT); + spans.extend(ansi_to_spans(&line.text, base_style)); + apply_search_style(index, Line::from(spans), app) +} + +fn format_hex_line( + index: usize, + line: &HexLine, + display: DisplayOptions, + app: &App, +) -> Line<'static> { + let mut spans = prefix_spans(line.elapsed, line.direction, &line.pipeline, display); + spans.push(Span::styled(line.hex.clone(), Style::default().fg(AMBER))); + spans.push(Span::styled(" |", Style::default().fg(MUTED))); + spans.push(Span::styled(line.ascii.clone(), Style::default().fg(TEXT))); + spans.push(Span::styled("|", Style::default().fg(MUTED))); + apply_search_style(index, Line::from(spans), app) +} + +fn prefix_spans( + elapsed: Duration, + direction: LineDirection, + pipeline: &str, + display: DisplayOptions, +) -> Vec> { + let mut spans = Vec::new(); if display.show_timestamp { - prefix.push(format!("[{}]", format_elapsed(line.elapsed))); + spans.push(Span::styled( + format!("[{}] ", format_elapsed(elapsed)), + Style::default().fg(MUTED), + )); } if display.show_direction { - prefix.push(format!( - "[{}]", - match line.direction { - LineDirection::In => "IN", - LineDirection::Out => "OUT", - } + let (label, color) = match direction { + LineDirection::In => ("IN", CYAN), + LineDirection::Out => ("OUT", AMBER), + }; + spans.push(Span::styled( + format!("[{label}] "), + Style::default().fg(color).add_modifier(Modifier::BOLD), )); } if display.show_pipeline { - prefix.push(format!("[{}]", line.pipeline)); - } - if prefix.is_empty() { - line.text.clone() - } else { - format!("{} {}", prefix.join(" "), line.text) - } -} - -fn format_hex_line(line: &HexLine, display: DisplayOptions) -> String { - let mut prefix = Vec::new(); - if display.show_timestamp { - prefix.push(format!("[{}]", format_elapsed(line.elapsed))); - } - if display.show_direction { - prefix.push(format!( - "[{}]", - match line.direction { - LineDirection::In => "IN", - LineDirection::Out => "OUT", - } + spans.push(Span::styled( + format!("[{pipeline}] "), + Style::default().fg(MAGENTA), )); } - if display.show_pipeline { - prefix.push(format!("[{}]", line.pipeline)); + spans +} + +fn apply_search_style(index: usize, line: Line<'static>, app: &App) -> Line<'static> { + if !app.search.active || app.search.query.is_empty() { + return line; } - let base = format!("{} |{}|", line.hex, line.ascii); - if prefix.is_empty() { - base + + if app.search.current_line() == Some(index) { + line.style(Style::default().bg(AMBER)) + } else if app.search.matches.contains(&index) { + line.style(Style::default().bg(Color::Rgb(47, 61, 91))) } else { - format!("{} {}", prefix.join(" "), base) + line } } -fn format_elapsed(elapsed: std::time::Duration) -> String { - let secs = elapsed.as_secs_f64(); - if secs < 60.0 { - format!("{secs:05.2}") - } else { - format!("{:02}:{:02}", (secs / 60.0) as u64, (secs % 60.0) as u64) +fn input_line(app: &App) -> Line<'static> { + if app.session.send_input.is_empty() { + return Line::styled( + match app.session.send_mode { + SendMode::Text => "type text payload", + SendMode::Hex => "hex bytes, e.g. 48 65 6c 6c 6f", + }, + Style::default().fg(MUTED), + ); } + + if !matches!(app.mode, AppMode::EditingSend) { + return Line::styled(app.session.send_input.clone(), Style::default().fg(TEXT)); + } + + let mut spans = Vec::new(); + let cursor = app.session.input_cursor; + for (index, ch) in app.session.send_input.chars().enumerate() { + if index == cursor { + spans.push(Span::styled( + ch.to_string(), + Style::default().fg(Color::Black).bg(MAGENTA), + )); + } else { + spans.push(Span::styled(ch.to_string(), Style::default().fg(TEXT))); + } + } + if cursor >= app.session.send_input.chars().count() { + spans.push(Span::styled(" ", Style::default().bg(MAGENTA))); + } + Line::from(spans) } -fn focus_style(focused: bool) -> Style { - if focused { - Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD) - } else { - Style::default() - } +fn section_line(label: &'static str) -> (Line<'static>, Option) { + ( + Line::styled( + format!("-- {label} "), + Style::default().fg(MUTED).add_modifier(Modifier::BOLD), + ), + None, + ) +} + +fn spacer_line() -> (Line<'static>, Option) { + (Line::raw(""), None) +} + +fn button_line( + label: &'static str, + color: Color, + action: Option, +) -> (Line<'static>, Option) { + ( + Line::from(vec![Span::styled( + label, + Style::default() + .fg(Color::Black) + .bg(color) + .add_modifier(Modifier::BOLD), + )]), + action, + ) +} + +fn toggle_line( + label: &'static str, + enabled: bool, + action: Option, +) -> (Line<'static>, Option) { + ( + Line::from(vec![ + Span::styled( + if enabled { "[x] " } else { "[ ] " }, + Style::default().fg(if enabled { GREEN } else { MUTED }), + ), + Span::styled(label, Style::default().fg(TEXT)), + ]), + action, + ) +} + +fn value_line( + label: &'static str, + value: &'static str, + action: Option, +) -> (Line<'static>, Option) { + ( + Line::from(vec![ + Span::styled(format!("{label:<8}"), Style::default().fg(MUTED)), + Span::styled(value.to_string(), Style::default().fg(CYAN)), + ]), + action, + ) +} + +fn panel_block(title: &str, focused: bool, color: Color) -> Block<'_> { + Block::default() + .borders(Borders::ALL) + .title(Span::styled( + format!(" {title} "), + Style::default() + .fg(if focused { Color::Black } else { color }) + .bg(if focused { color } else { PANEL }) + .add_modifier(Modifier::BOLD), + )) + .border_style(Style::default().fg(if focused { + color + } else { + Color::Rgb(45, 58, 74) + })) + .style(Style::default().bg(PANEL)) +} + +fn axis(title: &'static str, min: f64, max: f64, color: Color) -> Axis<'static> { + Axis::default() + .title(title) + .style(Style::default().fg(color)) + .bounds([min, max]) + .labels(vec![ + Span::styled(format!("{min:.2}"), Style::default().fg(MUTED)), + Span::styled(format!("{max:.2}"), Style::default().fg(MUTED)), + ]) } fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { @@ -330,3 +941,49 @@ fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { ]) .split(vertical[1])[1] } + +fn status_color(status: &ConnectionStatus) -> Color { + match status { + ConnectionStatus::Connected => GREEN, + ConnectionStatus::Disconnected => MUTED, + ConnectionStatus::Connecting => AMBER, + ConnectionStatus::Error(_) => RED, + } +} + +fn view_color(view: View) -> Color { + match view { + View::Text => CYAN, + View::Hex => AMBER, + View::Plot => GREEN, + } +} + +fn format_elapsed(elapsed: Duration) -> String { + let secs = elapsed.as_secs_f64(); + if secs < 60.0 { + format!("{secs:05.2}") + } else { + format!("{:02}:{:02}", (secs / 60.0) as u64, (secs % 60.0) as u64) + } +} + +fn next_line_ending(current: LineEnding) -> LineEnding { + let endings = LineEnding::ALL; + let index = endings + .iter() + .position(|ending| *ending == current) + .unwrap_or(0); + endings[(index + 1) % endings.len()] +} + +fn serial_ports_hint(app: &App) -> String { + if app.config_form.available_serial_ports.is_empty() { + String::from("Serial ports: none detected") + } else { + format!( + "Serial ports: {}", + app.config_form.available_serial_ports.join(", ") + ) + } +}