use std::io; use std::time::Duration; use crossterm::event::{ self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use pipeview_client::{ DecoderConfig, FramerConfig, PipelineConfig, SessionConfig, SessionEvent, SessionHandle, SessionId, SessionManager, }; use pipeview_core::protocol::DecodedData; 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::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 { Connected, Disconnected, Connecting, Error(String), } impl ConnectionStatus { pub fn label(&self) -> &'static str { match self { 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)] pub enum SendMode { Text, 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, pub show_direction: bool, pub show_pipeline: bool, } #[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 config: SessionConfig, pub status: ConnectionStatus, pub console: TextBuffer, pub hex: HexBuffer, pub plot: PlotBuffer, pub view: View, pub send_input: String, pub input_cursor: usize, pub send_mode: SendMode, 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, } 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, } } } #[derive(Clone, Copy)] pub enum TransportChoice { Serial, Tcp, Udp, } 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, pub serial_port: String, pub serial_baud_rate: String, pub serial_data_bits: SerialDataBits, 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 plot_frame_len: String, pub plot_channels: String, pub focused: usize, pub available_serial_ports: Vec, } impl ConfigForm { fn from_session(config: &SessionConfig) -> Self { let available_serial_ports = available_serial_ports(); 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:9000"), udp_remote_addr: String::new(), 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_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, }; match &config.transport { TransportConfig::Tcp { addr } => { form.transport = TransportChoice::Tcp; form.tcp_addr = addr.clone(); } TransportConfig::Udp { bind_addr, remote_addr, } => { form.transport = TransportChoice::Udp; form.udp_bind_addr = bind_addr.clone(); form.udp_remote_addr = remote_addr.clone().unwrap_or_default(); } TransportConfig::Serial { port, baud_rate, data_bits, parity, stop_bits, flow_control, .. } => { form.transport = TransportChoice::Serial; form.serial_port = port.clone(); form.serial_baud_rate = baud_rate.to_string(); form.serial_data_bits = *data_bits; form.serial_parity = *parity; form.serial_stop_bits = *stop_bits; form.serial_flow_control = *flow_control; } } 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 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 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 { 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); } } 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_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) { let count = self.fields().len(); self.focused = (self.focused + 1) % count; } fn prev_field(&mut self) { let count = self.fields().len(); self.focused = if self.focused == 0 { count.saturating_sub(1) } else { self.focused - 1 }; } fn cycle_current_option(&mut self, forward: bool) { 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, _ => {} } } fn cycle_serial_port(&mut self, forward: bool) { if self.available_serial_ports.is_empty() { return; } let next_index = self .available_serial_ports .iter() .position(|port| port == &self.serial_port) .map(|index| { if forward { (index + 1) % self.available_serial_ports.len() } else if index == 0 { self.available_serial_ports.len() - 1 } else { index - 1 } }) .unwrap_or(0); self.serial_port = self.available_serial_ports[next_index].clone(); } fn focused_text_field_mut(&mut self) -> Option<&mut String> { 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 numeric = matches!( self.focused_field(), ConfigField::SerialBaud | ConfigField::HistoryLimit | ConfigField::PlotFrameLen | ConfigField::PlotChannels ); if let Some(field) = self.focused_text_field_mut() { if numeric && !c.is_ascii_digit() { return; } field.push(c); } else if c == ' ' { self.cycle_current_option(true); } } fn backspace(&mut self) { if let Some(field) = self.focused_text_field_mut() { 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 { 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, } impl App { pub fn new() -> Self { let saved = app_state::load_tui_state(); 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, 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, } } pub async fn run( &mut self, terminal: &mut Terminal>, ) -> io::Result<()> { loop { self.drain_events(); terminal.draw(|frame| crate::ui::render(frame, self))?; if self.should_quit { break; } 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 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 }); } } 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, } } 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) } pub fn persist_state(&self) { app_state::save_tui_state(&self.session.config, self.session.view, self.display); } 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 { 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 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 { self.session.status = ConnectionStatus::Connecting; self.set_notice("Connect requested"); tokio::spawn(async move { let _ = handle.connect().await; }); } } 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 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"); } 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) { self.session.send_mode = match self.session.send_mode { SendMode::Text => SendMode::Hex, SendMode::Hex => SendMode::Text, }; } 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.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 payload = match self.build_payload() { Ok(Some(payload)) => payload, Ok(None) => { self.session.send_status = String::from("nothing to send"); return; } Err(message) => { self.session.send_status = format!("send failed: {message}"); return; } }; match self.session.send_mode { SendMode::Text => { let text = self.session.send_input.clone(); if !text.is_empty() { self.session.console.push_outbound(text); } } SendMode::Hex => { let compact = self .session .send_input .split_whitespace() .collect::>() .join(" "); 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; }); } 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})")) } } } 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() }; 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(), }; if self.search.case_sensitive { line.contains(&query) } else { line.to_lowercase().contains(&query) } }) .collect(); if self.search.current_match >= self.search.matches.len() { self.search.current_match = self.search.matches.len().saturating_sub(1); } } 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; }; let total = self.active_line_count(); self.scroll_from_bottom = total.saturating_sub(index + 1); } 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)); } 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 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, } } 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: 1_048_576, }, decoder: DecoderConfig::Text { encoding: TextEncoding::Utf8, }, }], 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 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; } 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}"), }, } } fn available_serial_ports() -> Vec { let mut ports: Vec<_> = SerialTransport::list_ports() .into_iter() .map(|port| port.port_name) .collect(); ports.sort(); ports.dedup(); ports } 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 { match value { SerialDataBits::Five => "5", SerialDataBits::Six => "6", SerialDataBits::Seven => "7", SerialDataBits::Eight => "8", } } fn serial_parity_name(value: SerialParity) -> &'static str { match value { SerialParity::None => "None", SerialParity::Odd => "Odd", SerialParity::Even => "Even", } } fn serial_stop_bits_name(value: SerialStopBits) -> &'static str { match value { SerialStopBits::One => "1", SerialStopBits::Two => "2", } } fn serial_flow_control_name(value: SerialFlowControl) -> &'static str { match value { SerialFlowControl::None => "None", SerialFlowControl::Software => "Software", SerialFlowControl::Hardware => "Hardware", } } fn next_serial_data_bits(value: SerialDataBits, forward: bool) -> SerialDataBits { match (value, forward) { (SerialDataBits::Five, true) => SerialDataBits::Six, (SerialDataBits::Six, true) => SerialDataBits::Seven, (SerialDataBits::Seven, true) => SerialDataBits::Eight, (SerialDataBits::Eight, true) => SerialDataBits::Five, (SerialDataBits::Five, false) => SerialDataBits::Eight, (SerialDataBits::Six, false) => SerialDataBits::Five, (SerialDataBits::Seven, false) => SerialDataBits::Six, (SerialDataBits::Eight, false) => SerialDataBits::Seven, } } fn next_serial_parity(value: SerialParity, forward: bool) -> SerialParity { match (value, forward) { (SerialParity::None, true) => SerialParity::Odd, (SerialParity::Odd, true) => SerialParity::Even, (SerialParity::Even, true) => SerialParity::None, (SerialParity::None, false) => SerialParity::Even, (SerialParity::Odd, false) => SerialParity::None, (SerialParity::Even, false) => SerialParity::Odd, } } fn next_serial_stop_bits(value: SerialStopBits, _forward: bool) -> SerialStopBits { match value { SerialStopBits::One => SerialStopBits::Two, SerialStopBits::Two => SerialStopBits::One, } } fn next_serial_flow_control(value: SerialFlowControl, forward: bool) -> SerialFlowControl { match (value, forward) { (SerialFlowControl::None, true) => SerialFlowControl::Software, (SerialFlowControl::Software, true) => SerialFlowControl::Hardware, (SerialFlowControl::Hardware, true) => SerialFlowControl::None, (SerialFlowControl::None, false) => SerialFlowControl::Hardware, (SerialFlowControl::Software, false) => SerialFlowControl::None, (SerialFlowControl::Hardware, false) => SerialFlowControl::Software, } }