From 8d9cc008c1fcba3356e6342938fd30c1a37ee9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:14:43 +0200 Subject: [PATCH 01/34] chore: sync Cargo.lock with 0.2.0 version bump Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ce09d19..829a5a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,7 +890,7 @@ dependencies = [ [[package]] name = "sermonizer" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "chrono", From bcb34ef2c1206bc167a8b20f284bd2caea5afd63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:15:08 +0200 Subject: [PATCH 02/34] fix: keep scroll-down and page-down anchored at bottom while following Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/app_state.rs | 53 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 4d28294..0c6516b 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -115,8 +115,10 @@ impl AppState { return; } - let selected = self.list_state.selected().unwrap_or(0); - if selected < self.output_lines.len() - 1 { + // While following, the implicit position is the last line + let last = self.output_lines.len() - 1; + let selected = self.list_state.selected().unwrap_or(last); + if selected < last { self.auto_scroll = false; self.list_state.select(Some(selected + 1)); self.needs_render = true; @@ -164,9 +166,10 @@ impl AppState { if self.output_lines.is_empty() { return; } - let current = self.list_state.selected().unwrap_or(0); - let new_selected = (current + page_size).min(self.output_lines.len().saturating_sub(1)); - if new_selected == self.output_lines.len().saturating_sub(1) { + let last = self.output_lines.len() - 1; + let current = self.list_state.selected().unwrap_or(last); + let new_selected = (current + page_size).min(last); + if new_selected == last { self.enable_auto_scroll(); } else { self.auto_scroll = false; @@ -289,3 +292,43 @@ impl AppState { self.needs_render = false; } } + +#[cfg(test)] +mod tests { + use super::*; + + fn state_with_lines(n: usize) -> AppState { + let mut state = AppState::new(false, false, String::new(), "LF"); + for i in 0..n { + state.add_notice(format!("line {i}")); + } + state + } + + #[test] + fn scroll_down_while_following_stays_at_bottom() { + let mut state = state_with_lines(50); + state.scroll_down(); + assert!(state.auto_scroll); + assert_eq!(state.list_state.selected(), None); + } + + #[test] + fn page_down_while_following_stays_at_bottom() { + let mut state = state_with_lines(50); + state.scroll_page_down(10); + assert!(state.auto_scroll); + assert_eq!(state.list_state.selected(), None); + } + + #[test] + fn scroll_up_then_down_moves_relative_to_bottom() { + let mut state = state_with_lines(50); + state.scroll_up(); + assert_eq!(state.list_state.selected(), Some(48)); + state.scroll_down(); + assert_eq!(state.list_state.selected(), Some(49)); + state.scroll_down(); + assert!(state.auto_scroll); + } +} From 9b5a8ae094f2d48e55cb0899722dfe91b99b0897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:15:27 +0200 Subject: [PATCH 03/34] fix: keep manual scroll offset anchored when output is trimmed Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/app_state.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 0c6516b..896f2e6 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -85,12 +85,14 @@ impl AppState { return; } self.output_lines.drain(..overflow); - // Keep the manual scroll position anchored to the same line while + // Keep the manual scroll window anchored to the same lines while // old lines are pruned from the front if let Some(selected) = self.list_state.selected() { self.list_state .select(Some(selected.saturating_sub(overflow))); } + let offset = self.list_state.offset(); + *self.list_state.offset_mut() = offset.saturating_sub(overflow); } pub fn scroll_up(&mut self) { @@ -321,6 +323,20 @@ mod tests { assert_eq!(state.list_state.selected(), None); } + #[test] + fn trimming_keeps_manual_scroll_window_anchored() { + let mut state = state_with_lines(MAX_OUTPUT_LINES); + state.scroll_up(); + state.list_state.select(Some(510)); + *state.list_state.offset_mut() = 500; + + for _ in 0..10 { + state.add_notice("new".to_string()); + } + assert_eq!(state.list_state.selected(), Some(500)); + assert_eq!(state.list_state.offset(), 490); + } + #[test] fn scroll_up_then_down_moves_relative_to_bottom() { let mut state = state_with_lines(50); From fad97b9305e47685c99ba887afe4b5e66eba69e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:16:04 +0200 Subject: [PATCH 04/34] fix: release writer port handle on disconnect Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/serial_io.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/serial_io.rs b/src/serial_io.rs index 013d6d5..660dc93 100644 --- a/src/serial_io.rs +++ b/src/serial_io.rs @@ -11,6 +11,7 @@ use crate::logging::LogSink; const READ_BUF_SIZE: usize = 4096; const RECONNECT_POLL: Duration = Duration::from_millis(100); const RECONNECT_RETRY_TICKS: u32 = 5; +const DISCONNECT_ACK_TIMEOUT: Duration = Duration::from_secs(1); /// Events sent from the serial threads to the UI. #[derive(Debug, Clone)] @@ -25,6 +26,10 @@ pub enum SerialEvent { pub enum WriterMsg { Data(Vec), NewPort(Box), + /// Release the current handle and acknowledge once it is dropped: a stale + /// descriptor on a vanished device can keep the OS from handing the same + /// name to the re-plugged board, so the reopen must wait for the drop + Disconnected(std::sync::mpsc::SyncSender<()>), } /// Supervises the reader thread: when the device disappears it keeps trying @@ -39,6 +44,7 @@ pub fn spawn_supervisor( ) -> JoinHandle<()> { std::thread::spawn(move || { let mut port = Some(first_port); + let mut reconnected = false; while running.load(Ordering::SeqCst) { let Some(p) = port.take() else { break }; @@ -55,6 +61,11 @@ pub fn spawn_supervisor( ))); } } + // Announce only once the writer can use the new connection + if reconnected { + let _ = events.send(SerialEvent::Reconnected); + reconnected = false; + } let reader = spawn_reader(p, running.clone(), events.clone(), rx_log.take()); rx_log = reader.join().unwrap_or(None); @@ -64,6 +75,11 @@ pub fn spawn_supervisor( // Reader exited while we are still running: the device is gone. // Poll until the port can be reopened. + let (ack_tx, ack_rx) = std::sync::mpsc::sync_channel(1); + if writer.send(WriterMsg::Disconnected(ack_tx)).is_err() { + break; + } + let _ = ack_rx.recv_timeout(DISCONNECT_ACK_TIMEOUT); let mut ticks = 0u32; while running.load(Ordering::SeqCst) && port.is_none() { std::thread::sleep(RECONNECT_POLL); @@ -72,8 +88,8 @@ pub fn spawn_supervisor( continue; } if let Ok(p) = settings.open() { - let _ = events.send(SerialEvent::Reconnected); port = Some(p); + reconnected = true; } } } @@ -132,6 +148,10 @@ pub fn spawn_writer( while let Ok(msg) = messages.recv() { match msg { WriterMsg::NewPort(p) => port = Some(p), + WriterMsg::Disconnected(ack) => { + port = None; + let _ = ack.send(()); + } WriterMsg::Data(bytes) => { let Some(p) = port.as_mut() else { let _ = @@ -155,3 +175,33 @@ pub fn spawn_writer( } }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writer_reports_not_connected_after_disconnect() { + let (writer_tx, writer_rx) = std::sync::mpsc::channel(); + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let handle = spawn_writer(writer_rx, event_tx, None); + + let (ack_tx, ack_rx) = std::sync::mpsc::sync_channel(1); + writer_tx + .send(WriterMsg::Disconnected(ack_tx)) + .expect("writer alive"); + ack_rx + .recv_timeout(DISCONNECT_ACK_TIMEOUT) + .expect("writer acknowledges the drop"); + writer_tx + .send(WriterMsg::Data(b"hi".to_vec())) + .expect("writer alive"); + drop(writer_tx); + handle.join().expect("writer thread exits cleanly"); + + match event_rx.try_recv() { + Ok(SerialEvent::Error(msg)) => assert!(msg.contains("not connected"), "{msg}"), + other => panic!("unexpected event: {other:?}"), + } + } +} From 8cef535561090a0d6cab65bafbd54253d59c6cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:16:42 +0200 Subject: [PATCH 05/34] build: use ratatui's crossterm re-export instead of a second crossterm Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- Cargo.lock | 89 ++----------------------------------------- Cargo.toml | 1 - src/main.rs | 6 +-- src/port_discovery.rs | 6 +-- src/ui/mod.rs | 10 ++--- 5 files changed, 12 insertions(+), 100 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 829a5a0..0cad1b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,15 +245,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "convert_case" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -280,25 +271,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.9.4", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix 1.0.8", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -358,36 +331,6 @@ dependencies = [ "syn", ] -[[package]] -name = "derive_more" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "document-features" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" -dependencies = [ - "litrs", -] - [[package]] name = "either" version = "1.15.0" @@ -584,18 +527,6 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - -[[package]] -name = "litrs" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" - [[package]] name = "lock_api" version = "0.4.13" @@ -778,7 +709,7 @@ dependencies = [ "bitflags 2.9.4", "cassowary", "compact_str", - "crossterm 0.28.1", + "crossterm", "indoc", "instability", "itertools", @@ -814,23 +745,10 @@ dependencies = [ "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.4.15", + "linux-raw-sys", "windows-sys 0.59.0", ] -[[package]] -name = "rustix" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" -dependencies = [ - "bitflags 2.9.4", - "errno", - "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -895,7 +813,6 @@ dependencies = [ "anyhow", "chrono", "clap", - "crossterm 0.29.0", "ctrlc", "ratatui", "serialport", diff --git a/Cargo.toml b/Cargo.toml index de26a24..7ce225a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ keywords = ["serial", "monitor", "embedded", "terminal", "tui"] [dependencies] anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } -crossterm = "0.29" ctrlc = "3.4" ratatui = "0.29" serialport = "4.3" diff --git a/src/main.rs b/src/main.rs index 9b176b2..4de53f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,9 +9,9 @@ use clap::Parser; use config::{ DataBitsArg, FlowControlArg, LineEnding, ParityArg, PortSettings, StopBitsArg, Toggle, UiConfig, }; -use crossterm::terminal; use logging::LogSink; use port_discovery::{choose_port_interactive, get_available_ports, print_ports}; +use ratatui::crossterm::terminal; use ratatui::{Terminal, backend::CrosstermBackend}; use serial_io::{SerialEvent, WriterMsg, spawn_supervisor, spawn_writer}; use std::path::PathBuf; @@ -202,7 +202,7 @@ async fn main() -> Result<()> { // Setup terminal for ratatui terminal::enable_raw_mode().context("Failed to enable raw mode")?; let mut stdout = std::io::stdout(); - crossterm::execute!(stdout, terminal::EnterAlternateScreen)?; + ratatui::crossterm::execute!(stdout, terminal::EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -219,7 +219,7 @@ async fn main() -> Result<()> { // Cleanup terminal terminal::disable_raw_mode()?; - crossterm::execute!(terminal.backend_mut(), terminal::LeaveAlternateScreen)?; + ratatui::crossterm::execute!(terminal.backend_mut(), terminal::LeaveAlternateScreen)?; terminal.show_cursor()?; // Ensure we stop and join the serial threads diff --git a/src/port_discovery.rs b/src/port_discovery.rs index 6957797..7780594 100644 --- a/src/port_discovery.rs +++ b/src/port_discovery.rs @@ -66,13 +66,13 @@ pub fn choose_port_interactive(ports: &[SerialPortInfo]) -> Result { println!(); // Temporarily disable raw mode if it was on (it isn't yet, but be safe) - let was_raw = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false); + let was_raw = ratatui::crossterm::terminal::is_raw_mode_enabled().unwrap_or(false); if was_raw { - let _ = crossterm::terminal::disable_raw_mode(); + let _ = ratatui::crossterm::terminal::disable_raw_mode(); } let selection = prompt_for_selection(ports.len()); if was_raw { - let _ = crossterm::terminal::enable_raw_mode(); + let _ = ratatui::crossterm::terminal::enable_raw_mode(); } let name = ports[selection?].port_name.clone(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0b1d8a1..59afb2a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,7 +6,7 @@ pub use app_state::AppState; pub use rendering::draw_ui; use anyhow::Result; -use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{Terminal, backend::Backend}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -124,11 +124,7 @@ fn handle_serial_event(event: SerialEvent, app_state: &mut AppState) { } } -fn handle_key_event( - key: crossterm::event::KeyEvent, - app_state: &mut AppState, - ui_config: &UiConfig, -) { +fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfig) { // Ctrl+V arms literal mode: the next key is sent as a raw control byte if app_state.pending_literal { app_state.pending_literal = false; @@ -210,7 +206,7 @@ fn handle_key_event( } /// Map a key pressed after Ctrl+V to the raw byte it should send. -fn literal_byte(key: crossterm::event::KeyEvent) -> Option { +fn literal_byte(key: KeyEvent) -> Option { match key.code { KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => { let c = c.to_ascii_uppercase(); From fb5a46d59520131cfb6299b9d42d9e81012a9b18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:17:15 +0200 Subject: [PATCH 06/34] fix: restore terminal on panic and setup errors Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/main.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4de53f5..7dd7b45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,8 +11,6 @@ use config::{ }; use logging::LogSink; use port_discovery::{choose_port_interactive, get_available_ports, print_ports}; -use ratatui::crossterm::terminal; -use ratatui::{Terminal, backend::CrosstermBackend}; use serial_io::{SerialEvent, WriterMsg, spawn_supervisor, spawn_writer}; use std::path::PathBuf; use std::sync::{ @@ -199,12 +197,12 @@ async fn main() -> Result<()> { rx_log, ); - // Setup terminal for ratatui - terminal::enable_raw_mode().context("Failed to enable raw mode")?; - let mut stdout = std::io::stdout(); - ratatui::crossterm::execute!(stdout, terminal::EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; + // Raw mode + alternate screen, with a panic hook that restores both + let mut terminal = ratatui::try_init() + .inspect_err(|_| { + let _ = ratatui::try_restore(); + }) + .context("Failed to set up terminal")?; let ui_config = UiConfig { running: running.clone(), @@ -217,9 +215,8 @@ async fn main() -> Result<()> { let ui_res = run_ui(&mut terminal, ui_rx, event_rx, ui_config).await; - // Cleanup terminal - terminal::disable_raw_mode()?; - ratatui::crossterm::execute!(terminal.backend_mut(), terminal::LeaveAlternateScreen)?; + // Restore terminal before anything else can fail + ratatui::try_restore().context("Failed to restore terminal")?; terminal.show_cursor()?; // Ensure we stop and join the serial threads From ff3b1f1d9467fd2c15d7249a86b85ca2610a45af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:17:51 +0200 Subject: [PATCH 07/34] perf: coalesce queued serial and input events into one frame Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/mod.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 59afb2a..f7da846 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -65,15 +65,20 @@ pub async fn run_ui( // Terminal events from the blocking input thread input = input_rx.recv() => { match input { - Some(Event::Key(k)) if k.kind == KeyEventKind::Press => { - handle_key_event(k, &mut app_state, &ui_config); - } - Some(Event::Resize(_, _)) => app_state.needs_render = true, - Some(_) => {} + Some(ev) => handle_input_event(ev, &mut app_state, &ui_config), None => app_state.quit(), } } } + + // Fold everything already queued into the same frame: a fast serial + // stream arrives in many small reads and must not cost a draw each + while let Ok(event) = serial_rx.try_recv() { + handle_serial_event(event, &mut app_state); + } + while let Ok(ev) = input_rx.try_recv() { + handle_input_event(ev, &mut app_state, &ui_config); + } } ui_config.running.store(false, Ordering::SeqCst); @@ -100,6 +105,16 @@ fn spawn_input_thread( (rx, handle) } +fn handle_input_event(event: Event, app_state: &mut AppState, ui_config: &UiConfig) { + match event { + Event::Key(k) if k.kind == KeyEventKind::Press => { + handle_key_event(k, app_state, ui_config); + } + Event::Resize(_, _) => app_state.needs_render = true, + _ => {} + } +} + fn handle_serial_event(event: SerialEvent, app_state: &mut AppState) { match event { SerialEvent::Data(bytes) => { From f70769460952bb662297e3fcfdc8f0875d60d2ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:17:59 +0200 Subject: [PATCH 08/34] refactor: state invariant instead of bare unwrap when splitting long lines Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/line_assembler.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ui/line_assembler.rs b/src/ui/line_assembler.rs index e757a2b..994e73e 100644 --- a/src/ui/line_assembler.rs +++ b/src/ui/line_assembler.rs @@ -54,7 +54,10 @@ impl LineAssembler { && ((b & 0xC0 != 0x80 && b != b'\r') || self.partial.len() >= MAX_TEXT_LINE_BYTES + 4) { - done.push(self.finish().unwrap()); + done.push( + self.finish() + .expect("partial holds at least MAX_TEXT_LINE_BYTES bytes"), + ); if self.timestamps { self.line_ts = Some(timestamp()); } From 1b793fcd9db2743e8f1bea7192a651de50184091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:18:39 +0200 Subject: [PATCH 09/34] feat: strip ANSI escape sequences from text output Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/line_assembler.rs | 139 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 2 deletions(-) diff --git a/src/ui/line_assembler.rs b/src/ui/line_assembler.rs index 994e73e..b96fe67 100644 --- a/src/ui/line_assembler.rs +++ b/src/ui/line_assembler.rs @@ -3,13 +3,34 @@ use std::fmt::Write as _; const HEX_BYTES_PER_LINE: usize = 16; const MAX_TEXT_LINE_BYTES: usize = 4096; +const ESC: u8 = 0x1B; +const BEL: u8 = 0x07; + +/// Position inside an ANSI escape sequence while parsing text mode input. +/// Byte classes follow ECMA-48; anything outside them aborts the sequence so +/// line noise stays visible instead of being swallowed. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum EscapeState { + Text, + /// After ESC: intermediates (0x20..=0x2F) then a final byte (0x30..=0x7E) + Escape, + /// After ESC [: parameters and intermediates (0x20..=0x3F) then a final + /// byte (0x40..=0x7E) + Csi, + /// Inside OSC/DCS/SOS/PM/APC, which run until BEL or ESC \ + Str, + /// ESC seen inside a string sequence, deciding whether it terminates it + StrEsc, +} /// Assembles raw serial bytes into display lines. Text mode buffers raw bytes -/// into bounded lines so multi-byte UTF-8 sequences split across reads survive; -/// hex mode emits fixed-width rows. +/// into bounded lines so multi-byte UTF-8 sequences split across reads survive +/// and drops ANSI escape sequences the list widget cannot render; hex mode +/// emits fixed-width rows. pub struct LineAssembler { hex: bool, timestamps: bool, + escape: EscapeState, partial: Vec, hex_row: String, hex_col: usize, @@ -21,6 +42,7 @@ impl LineAssembler { Self { hex, timestamps, + escape: EscapeState::Text, partial: Vec::with_capacity(256), hex_row: String::new(), hex_col: 0, @@ -40,10 +62,17 @@ impl LineAssembler { fn push_text(&mut self, bytes: &[u8]) -> Vec { let mut done = Vec::new(); for &b in bytes { + if b != b'\n' && self.consume_escape(b) { + continue; + } + // Only displayed bytes start a line, so escape prefixes such as a + // screen clear do not stamp a line that arrives later if self.timestamps && self.line_ts.is_none() { self.line_ts = Some(timestamp()); } if b == b'\n' { + // A newline always ends the line, even inside a broken escape + self.escape = EscapeState::Text; let mut line = self.line_ts.take().unwrap_or_default(); let raw = self.partial.strip_suffix(b"\r").unwrap_or(&self.partial); line.push_str(&String::from_utf8_lossy(raw)); @@ -68,6 +97,38 @@ impl LineAssembler { done } + /// Track ANSI escape sequences; returns true when the byte belongs to one + /// and must not be displayed. + fn consume_escape(&mut self, b: u8) -> bool { + use EscapeState::*; + let (next, consumed) = match (self.escape, b) { + (Text, ESC) => (Escape, true), + (Text, _) => (Text, false), + + (Escape, ESC) => (Escape, true), + (Escape, b'[') => (Csi, true), + (Escape, b']' | b'P' | b'X' | b'^' | b'_') => (Str, true), + (Escape, 0x20..=0x2F) => (Escape, true), + (Escape, 0x30..=0x7E) => (Text, true), + (Escape, _) => (Text, false), + + (Csi, ESC) => (Escape, true), + (Csi, 0x20..=0x3F) => (Csi, true), + (Csi, 0x40..=0x7E) => (Text, true), + (Csi, _) => (Text, false), + + (Str, BEL) => (Text, true), + (Str, ESC) => (StrEsc, true), + (Str, _) => (Str, true), + + (StrEsc, b'\\') => (Text, true), + (StrEsc, ESC) => (StrEsc, true), + (StrEsc, _) => (Str, true), + }; + self.escape = next; + consumed + } + fn push_hex(&mut self, bytes: &[u8]) -> Vec { let mut done = Vec::new(); for &b in bytes { @@ -117,6 +178,7 @@ impl LineAssembler { } pub fn clear(&mut self) { + self.escape = EscapeState::Text; self.partial.clear(); self.hex_row.clear(); self.hex_col = 0; @@ -171,6 +233,79 @@ mod tests { assert_eq!(asm.partial_display(), None); } + #[test] + fn ansi_escape_sequences_are_stripped_from_text() { + let mut asm = LineAssembler::new(false, false); + let line = b"\x1b[0;32mI (123) main: ok\x1b[0m\r\n"; + assert_eq!(asm.push(line), vec!["I (123) main: ok".to_string()]); + } + + #[test] + fn ansi_escape_split_across_chunks_is_stripped() { + let mut asm = LineAssembler::new(false, false); + assert!(asm.push(b"a\x1b[").is_empty()); + assert_eq!(asm.partial_display().as_deref(), Some("a")); + assert_eq!(asm.push(b"1;31mb\n"), vec!["ab".to_string()]); + } + + #[test] + fn two_byte_escape_and_newline_inside_escape_are_handled() { + let mut asm = LineAssembler::new(false, false); + // ESC c (reset) is a two-byte sequence; a newline aborts a broken one + assert_eq!( + asm.push(b"\x1bcx\x1b[9\ny\n"), + vec!["x".to_string(), "y".to_string()] + ); + } + + #[test] + fn bytes_outside_csi_ranges_abort_the_sequence_and_stay_visible() { + let mut asm = LineAssembler::new(false, false); + // Line noise: ESC [ followed by high bytes must not swallow the text + let out = asm.push(b"good\x1b[\x80\x81 lots of text\n"); + assert_eq!(out, vec!["good\u{FFFD}\u{FFFD} lots of text".to_string()]); + // ESC followed by a UTF-8 character keeps the character intact + assert_eq!( + asm.push("\x1b\u{fc}\n".as_bytes()), + vec!["\u{fc}".to_string()] + ); + // Doubled ESC still strips the following SGR + assert_eq!(asm.push(b"\x1b\x1b[31mred\n"), vec!["red".to_string()]); + } + + #[test] + fn string_sequences_are_consumed_up_to_their_terminator() { + let mut asm = LineAssembler::new(false, false); + assert_eq!( + asm.push(b"\x1b]0;my board\x07hello\n"), + vec!["hello".to_string()] + ); + assert_eq!( + asm.push(b"\x1b]8;;https://x.io\x1b\\click\x1b]8;;\x1b\\ done\n"), + vec!["click done".to_string()] + ); + assert_eq!( + asm.push(b"\x1bPq#0;2\x1b\\tail\n"), + vec!["tail".to_string()] + ); + } + + #[test] + fn timestamp_is_not_taken_from_escape_bytes() { + let mut asm = LineAssembler::new(false, true); + assert!(asm.push(b"\x1b[2J").is_empty()); + assert_eq!(asm.line_ts, None); + assert_eq!(asm.partial_display(), None); + assert!(asm.push(b"x\n")[0].ends_with("] x")); + } + + #[test] + fn hex_mode_keeps_escape_bytes() { + let mut asm = LineAssembler::new(true, false); + asm.push(b"\x1b["); + assert_eq!(asm.partial_display().as_deref(), Some("1B 5B")); + } + #[test] fn hex_rows_wrap_at_sixteen_bytes() { let mut asm = LineAssembler::new(true, false); From b49e73c663f8d7e35012fa1412acf63b8ef5e8e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:19:05 +0200 Subject: [PATCH 10/34] fix: report Ctrl+V keys that have no literal byte mapping Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/mod.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f7da846..b4637ef 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -144,12 +144,17 @@ fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfi if app_state.pending_literal { app_state.pending_literal = false; app_state.needs_render = true; - if let Some(byte) = literal_byte(key) { - if ui_config.writer.send(WriterMsg::Data(vec![byte])).is_err() { - app_state.add_notice("[sermonizer] writer stopped, input dropped".to_string()); - } else { - app_state.add_notice(format!("[sermonizer] sent control byte 0x{byte:02X}")); - } + let Some(byte) = literal_byte(key) else { + app_state.add_notice( + "[sermonizer] no literal byte for that key, nothing sent (use Ctrl+A..Z, Esc, Enter or Tab)" + .to_string(), + ); + return; + }; + if ui_config.writer.send(WriterMsg::Data(vec![byte])).is_err() { + app_state.add_notice("[sermonizer] writer stopped, input dropped".to_string()); + } else { + app_state.add_notice(format!("[sermonizer] sent control byte 0x{byte:02X}")); } return; } @@ -254,6 +259,40 @@ fn handle_enter_key(app_state: &mut AppState, ui_config: &UiConfig) { #[cfg(test)] mod tests { use super::*; + use crate::config::LineEnding; + + fn test_config() -> (UiConfig, std::sync::mpsc::Receiver) { + let (writer, writer_rx) = std::sync::mpsc::channel(); + let config = UiConfig { + running: Arc::new(AtomicBool::new(true)), + line_ending: LineEnding::Nl, + writer, + hex: false, + show_ts: false, + port_label: String::new(), + }; + (config, writer_rx) + } + + #[test] + fn literal_mode_reports_keys_without_a_mapping() { + let (config, writer_rx) = test_config(); + let mut state = AppState::new(false, false, String::new(), "LF"); + handle_key_event( + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), + &mut state, + &config, + ); + assert!(state.pending_literal); + handle_key_event(KeyEvent::from(KeyCode::Char('x')), &mut state, &config); + assert!(!state.pending_literal); + assert!(writer_rx.try_recv().is_err(), "nothing must be sent"); + assert!( + state.output_lines[0].contains("nothing sent"), + "{:?}", + state.output_lines + ); + } #[test] fn disconnect_finishes_partial_output_before_notices() { From 66e3a959f363ae9b10f1f0d1ca20dbd2e5f7d88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:19:19 +0200 Subject: [PATCH 11/34] chore: drop unused chrono serde and tokio time features Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- Cargo.lock | 21 --------------------- Cargo.toml | 5 ++--- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cad1b4..ef4a951 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,7 +180,6 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", - "serde", "wasm-bindgen", "windows-link", ] @@ -767,26 +766,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "serde" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "serialport" version = "4.7.3" diff --git a/Cargo.toml b/Cargo.toml index 7ce225a..4be0641 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ authors = [ categories = ["command-line-utilities", "development-tools", "embedded"] description = "A simple, clean serial monitor with a clean terminal UI for embedded development" license = "MIT" -readme = "README.md" repository = "https://github.com/systemscape/sermonizer" keywords = ["serial", "monitor", "embedded", "terminal", "tui"] @@ -19,8 +18,8 @@ clap = { version = "4.5", features = ["derive"] } ctrlc = "3.4" ratatui = "0.29" serialport = "4.3" -tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "time", "macros"] } -chrono = { version = "0.4", features = ["serde"] } +tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros"] } +chrono = "0.4" unicode-width = "0.2" # The profile that 'dist' will build with From 0bb4e8f6ebdf743826693476a2bd9e2ff7747f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:31:30 +0200 Subject: [PATCH 12/34] fix: stop and join input thread when the UI loop fails Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/mod.rs | 76 +++++++++++++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b4637ef..993f9e4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -35,55 +35,61 @@ pub async fn run_ui( ); let (mut input_rx, input_handle) = spawn_input_thread(ui_config.running.clone()); - loop { - // Only render if state changed - major performance optimization - if app_state.needs_render { - terminal.draw(|f| draw_ui(f, &mut app_state))?; - app_state.mark_rendered(); - } + // Run the loop in a block so the input thread is stopped and joined on + // every exit path, including a failed draw + let result: Result<()> = async { + loop { + // Only render if state changed - major performance optimization + if app_state.needs_render { + terminal.draw(|f| draw_ui(f, &mut app_state))?; + app_state.mark_rendered(); + } - if !ui_config.running.load(Ordering::SeqCst) || app_state.should_quit { - break; - } + if !ui_config.running.load(Ordering::SeqCst) || app_state.should_quit { + break; + } - tokio::select! { - // UI messages (like quit from Ctrl-C) - msg = ui_rx.recv() => { - match msg { - Some(UiMessage::Quit) | None => app_state.quit(), + tokio::select! { + // UI messages (like quit from Ctrl-C) + msg = ui_rx.recv() => { + match msg { + Some(UiMessage::Quit) | None => app_state.quit(), + } } - } - // Serial events - event = serial_rx.recv() => { - match event { - Some(event) => handle_serial_event(event, &mut app_state), - None => app_state.quit(), + // Serial events + event = serial_rx.recv() => { + match event { + Some(event) => handle_serial_event(event, &mut app_state), + None => app_state.quit(), + } } - } - // Terminal events from the blocking input thread - input = input_rx.recv() => { - match input { - Some(ev) => handle_input_event(ev, &mut app_state, &ui_config), - None => app_state.quit(), + // Terminal events from the blocking input thread + input = input_rx.recv() => { + match input { + Some(ev) => handle_input_event(ev, &mut app_state, &ui_config), + None => app_state.quit(), + } } } - } - // Fold everything already queued into the same frame: a fast serial - // stream arrives in many small reads and must not cost a draw each - while let Ok(event) = serial_rx.try_recv() { - handle_serial_event(event, &mut app_state); - } - while let Ok(ev) = input_rx.try_recv() { - handle_input_event(ev, &mut app_state, &ui_config); + // Fold everything already queued into the same frame: a fast serial + // stream arrives in many small reads and must not cost a draw each + while let Ok(event) = serial_rx.try_recv() { + handle_serial_event(event, &mut app_state); + } + while let Ok(ev) = input_rx.try_recv() { + handle_input_event(ev, &mut app_state, &ui_config); + } } + Ok(()) } + .await; ui_config.running.store(false, Ordering::SeqCst); let _ = input_handle.join(); - Ok(()) + result } /// Reads terminal events on a dedicated thread so the UI loop can await them From 1631b6fe2e9df0ae3a281e59cb0a78e5764de6b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:47:10 +0200 Subject: [PATCH 13/34] feat: add --raw flag to keep ANSI escape sequences Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 2 ++ src/config.rs | 2 ++ src/main.rs | 8 +++++++ src/ui/app_state.rs | 5 ++-- src/ui/line_assembler.rs | 52 +++++++++++++++++++++++++--------------- src/ui/mod.rs | 6 +++-- 6 files changed, 52 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a014578..b9f6e71 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Most terminal-based serial monitors are annoying to use - they have clunky inter - **Auto-detect ports**: Just run `sermonizer` and it finds your device - **Sane defaults**: 115200 baud, 8 data bits, no parity, 1 stop bit - **Hex mode**: View binary data as hex bytes +- **Clean text**: ANSI colour codes from firmware logs are stripped (keep them with `--raw`) - **Logging**: Save RX/TX data with timestamps - **Fast**: Built in Rust, handles high baud rates smoothly @@ -65,6 +66,7 @@ Options: --dtr Set the DTR line after opening --rts Set the RTS line after opening --hex Display data as hex + --raw Keep ANSI escape sequences instead of stripping them --log Log received data --tx-log Log transmitted data --log-ts Add timestamps to logs and display diff --git a/src/config.rs b/src/config.rs index 454159c..d563b60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -213,5 +213,7 @@ pub struct UiConfig { pub writer: std::sync::mpsc::Sender, pub hex: bool, pub show_ts: bool, + /// Keep ANSI escape sequences in the display instead of stripping them + pub raw: bool, pub port_label: String, } diff --git a/src/main.rs b/src/main.rs index 7dd7b45..2a2ea60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,6 +76,10 @@ struct Args { #[arg(long)] hex: bool, + /// Keep ANSI escape sequences in the display instead of stripping them + #[arg(long)] + raw: bool, + /// Just list ports and exit #[arg(long)] list: bool, @@ -124,6 +128,9 @@ async fn main() -> Result<()> { if args.hex { println!("RX view: HEX"); } + if args.raw { + println!("ANSI escapes: kept"); + } if args.log_ts { println!("Timestamps in logs: ON"); } @@ -210,6 +217,7 @@ async fn main() -> Result<()> { writer: writer_tx.clone(), hex: args.hex, show_ts: args.log_ts, + raw: args.raw, port_label: format!("{port_name} @ {baud}"), }; diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 896f2e6..dc97af3 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -29,6 +29,7 @@ impl AppState { pub fn new( hex: bool, timestamps: bool, + strip_ansi: bool, port_label: String, line_ending_label: &'static str, ) -> Self { @@ -40,7 +41,7 @@ impl AppState { draft: String::new(), pending_literal: false, output_lines: VecDeque::with_capacity(MAX_OUTPUT_LINES), - assembler: LineAssembler::new(hex, timestamps), + assembler: LineAssembler::new(hex, timestamps, strip_ansi), list_state: ListState::default(), auto_scroll_state: ListState::default(), should_quit: false, @@ -300,7 +301,7 @@ mod tests { use super::*; fn state_with_lines(n: usize) -> AppState { - let mut state = AppState::new(false, false, String::new(), "LF"); + let mut state = AppState::new(false, false, true, String::new(), "LF"); for i in 0..n { state.add_notice(format!("line {i}")); } diff --git a/src/ui/line_assembler.rs b/src/ui/line_assembler.rs index b96fe67..e803aea 100644 --- a/src/ui/line_assembler.rs +++ b/src/ui/line_assembler.rs @@ -30,6 +30,7 @@ enum EscapeState { pub struct LineAssembler { hex: bool, timestamps: bool, + strip_ansi: bool, escape: EscapeState, partial: Vec, hex_row: String, @@ -38,10 +39,11 @@ pub struct LineAssembler { } impl LineAssembler { - pub fn new(hex: bool, timestamps: bool) -> Self { + pub fn new(hex: bool, timestamps: bool, strip_ansi: bool) -> Self { Self { hex, timestamps, + strip_ansi, escape: EscapeState::Text, partial: Vec::with_capacity(256), hex_row: String::new(), @@ -101,6 +103,9 @@ impl LineAssembler { /// and must not be displayed. fn consume_escape(&mut self, b: u8) -> bool { use EscapeState::*; + if !self.strip_ansi { + return false; + } let (next, consumed) = match (self.escape, b) { (Text, ESC) => (Escape, true), (Text, _) => (Text, false), @@ -208,7 +213,7 @@ mod tests { #[test] fn text_line_split_across_chunks_completes_once() { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); assert!(asm.push(b"hel").is_empty()); assert_eq!(asm.partial_display().as_deref(), Some("hel")); assert_eq!(asm.push(b"lo\nwor"), vec!["hello".to_string()]); @@ -217,14 +222,14 @@ mod tests { #[test] fn crlf_is_trimmed_from_completed_and_partial_lines() { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); assert_eq!(asm.push(b"one\r\ntwo\r"), vec!["one".to_string()]); assert_eq!(asm.partial_display().as_deref(), Some("two")); } #[test] fn utf8_sequence_split_across_chunks_stays_intact() { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); let bytes = "grΓΌn\n".as_bytes(); assert!(asm.push(&bytes[..3]).is_empty()); // Incomplete trailing sequence is hidden, not shown as replacement char @@ -235,14 +240,14 @@ mod tests { #[test] fn ansi_escape_sequences_are_stripped_from_text() { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); let line = b"\x1b[0;32mI (123) main: ok\x1b[0m\r\n"; assert_eq!(asm.push(line), vec!["I (123) main: ok".to_string()]); } #[test] fn ansi_escape_split_across_chunks_is_stripped() { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); assert!(asm.push(b"a\x1b[").is_empty()); assert_eq!(asm.partial_display().as_deref(), Some("a")); assert_eq!(asm.push(b"1;31mb\n"), vec!["ab".to_string()]); @@ -250,7 +255,7 @@ mod tests { #[test] fn two_byte_escape_and_newline_inside_escape_are_handled() { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); // ESC c (reset) is a two-byte sequence; a newline aborts a broken one assert_eq!( asm.push(b"\x1bcx\x1b[9\ny\n"), @@ -260,7 +265,7 @@ mod tests { #[test] fn bytes_outside_csi_ranges_abort_the_sequence_and_stay_visible() { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); // Line noise: ESC [ followed by high bytes must not swallow the text let out = asm.push(b"good\x1b[\x80\x81 lots of text\n"); assert_eq!(out, vec!["good\u{FFFD}\u{FFFD} lots of text".to_string()]); @@ -275,7 +280,7 @@ mod tests { #[test] fn string_sequences_are_consumed_up_to_their_terminator() { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); assert_eq!( asm.push(b"\x1b]0;my board\x07hello\n"), vec!["hello".to_string()] @@ -292,23 +297,32 @@ mod tests { #[test] fn timestamp_is_not_taken_from_escape_bytes() { - let mut asm = LineAssembler::new(false, true); + let mut asm = LineAssembler::new(false, true, true); assert!(asm.push(b"\x1b[2J").is_empty()); assert_eq!(asm.line_ts, None); assert_eq!(asm.partial_display(), None); assert!(asm.push(b"x\n")[0].ends_with("] x")); } + #[test] + fn raw_mode_keeps_escape_sequences() { + let mut asm = LineAssembler::new(false, false, false); + assert_eq!( + asm.push(b"\x1b[31mred\x1b[0m\n"), + vec!["\x1b[31mred\x1b[0m".to_string()] + ); + } + #[test] fn hex_mode_keeps_escape_bytes() { - let mut asm = LineAssembler::new(true, false); + let mut asm = LineAssembler::new(true, false, true); asm.push(b"\x1b["); assert_eq!(asm.partial_display().as_deref(), Some("1B 5B")); } #[test] fn hex_rows_wrap_at_sixteen_bytes() { - let mut asm = LineAssembler::new(true, false); + let mut asm = LineAssembler::new(true, false, true); let completed = asm.push(&[0xDE; 18]); assert_eq!(completed, vec!["DE ".repeat(15) + "DE"]); assert_eq!(asm.partial_display().as_deref(), Some("DE DE")); @@ -316,7 +330,7 @@ mod tests { #[test] fn timestamps_prefix_each_completed_line() { - let mut asm = LineAssembler::new(false, true); + let mut asm = LineAssembler::new(false, true, true); let completed = asm.push(b"a\nb\n"); assert_eq!(completed.len(), 2); for line in &completed { @@ -327,7 +341,7 @@ mod tests { #[test] fn clear_resets_partial_state() { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); asm.push(b"pending"); asm.clear(); assert_eq!(asm.partial_display(), None); @@ -336,7 +350,7 @@ mod tests { #[test] fn newline_free_stream_is_bounded_and_preserved() { for byte in [b'a', 0x80, b'\r'] { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); let mut lines = Vec::new(); let chunk = vec![byte; 997]; for _ in 0..100 { @@ -352,7 +366,7 @@ mod tests { fn long_lines_preserve_utf8_at_each_split_boundary() { for offset in 0..4 { let text = "a".repeat(MAX_TEXT_LINE_BYTES - offset) + "πŸ¦€next\r\n"; - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); let mut lines = Vec::new(); for byte in text.bytes() { lines.extend(asm.push(&[byte])); @@ -364,7 +378,7 @@ mod tests { #[test] fn newline_at_limit_does_not_create_an_extra_line() { - let mut asm = LineAssembler::new(false, false); + let mut asm = LineAssembler::new(false, false, true); let text = "a".repeat(MAX_TEXT_LINE_BYTES); assert!(asm.push(text.as_bytes()).is_empty()); assert_eq!(asm.push(b"\r\n"), vec![text]); @@ -372,7 +386,7 @@ mod tests { #[test] fn finish_preserves_incomplete_utf8_and_resets_timestamps() { - let mut asm = LineAssembler::new(false, true); + let mut asm = LineAssembler::new(false, true, true); asm.push(b"before\xF0\x9F"); assert!(asm.finish().unwrap().ends_with("beforeοΏ½")); assert_eq!(asm.finish(), None); @@ -382,7 +396,7 @@ mod tests { #[test] fn finish_resets_partial_hex_rows() { - let mut asm = LineAssembler::new(true, false); + let mut asm = LineAssembler::new(true, false, true); asm.push(&[0xAB; 3]); assert_eq!(asm.finish().as_deref(), Some("AB AB AB")); assert_eq!(asm.finish(), None); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 993f9e4..cba2db6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -30,6 +30,7 @@ pub async fn run_ui( let mut app_state = AppState::new( ui_config.hex, ui_config.show_ts, + !ui_config.raw, ui_config.port_label.clone(), ui_config.line_ending.describe(), ); @@ -275,6 +276,7 @@ mod tests { writer, hex: false, show_ts: false, + raw: false, port_label: String::new(), }; (config, writer_rx) @@ -283,7 +285,7 @@ mod tests { #[test] fn literal_mode_reports_keys_without_a_mapping() { let (config, writer_rx) = test_config(); - let mut state = AppState::new(false, false, String::new(), "LF"); + let mut state = AppState::new(false, false, true, String::new(), "LF"); handle_key_event( KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), &mut state, @@ -303,7 +305,7 @@ mod tests { #[test] fn disconnect_finishes_partial_output_before_notices() { for hex in [false, true] { - let mut state = AppState::new(hex, false, String::new(), "LF"); + let mut state = AppState::new(hex, false, true, String::new(), "LF"); handle_serial_event(SerialEvent::Data(b"before".to_vec()), &mut state); handle_serial_event(SerialEvent::Disconnected("EOF".into()), &mut state); assert!(!state.connected); From 1baa245fdb00aadf43f59998fcac29831bde9f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:48:43 +0200 Subject: [PATCH 14/34] fix: anchor manual scrolling below the partial line row Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/app_state.rs | 34 +++++++++++++++++++++++++++++++--- src/ui/line_assembler.rs | 9 +++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index dc97af3..334bd4d 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -96,6 +96,17 @@ impl AppState { *self.list_state.offset_mut() = offset.saturating_sub(overflow); } + /// Index of the bottom rendered row while following: the partial line, + /// when shown, sits below the last complete line + fn follow_position(&self) -> usize { + let last = self.output_lines.len() - 1; + if self.assembler.has_partial() { + last + 1 + } else { + last + } + } + pub fn scroll_up(&mut self) { if self.output_lines.is_empty() { return; @@ -106,7 +117,7 @@ impl AppState { let selected = self .list_state .selected() - .unwrap_or(self.output_lines.len() - 1); + .unwrap_or_else(|| self.follow_position()); if selected > 0 { self.list_state.select(Some(selected - 1)); self.needs_render = true; @@ -156,11 +167,12 @@ impl AppState { return; } self.auto_scroll = false; + let last = self.output_lines.len() - 1; let current = self .list_state .selected() - .unwrap_or(self.output_lines.len().saturating_sub(1)); - let new_selected = current.saturating_sub(page_size); + .unwrap_or_else(|| self.follow_position()); + let new_selected = current.saturating_sub(page_size).min(last); self.list_state.select(Some(new_selected)); self.needs_render = true; } @@ -338,6 +350,22 @@ mod tests { assert_eq!(state.list_state.offset(), 490); } + #[test] + fn scroll_up_from_follow_lands_on_last_complete_line_above_partial() { + let mut state = state_with_lines(50); + state.add_data(b"partial"); + state.scroll_up(); + assert_eq!(state.list_state.selected(), Some(49)); + } + + #[test] + fn page_up_from_follow_counts_the_partial_row() { + let mut state = state_with_lines(50); + state.add_data(b"partial"); + state.scroll_page_up(10); + assert_eq!(state.list_state.selected(), Some(40)); + } + #[test] fn scroll_up_then_down_moves_relative_to_bottom() { let mut state = state_with_lines(50); diff --git a/src/ui/line_assembler.rs b/src/ui/line_assembler.rs index e803aea..2c5939c 100644 --- a/src/ui/line_assembler.rs +++ b/src/ui/line_assembler.rs @@ -154,6 +154,15 @@ impl LineAssembler { done } + /// Whether an unfinished line is currently shown below the output. + pub fn has_partial(&self) -> bool { + if self.hex { + !self.hex_row.is_empty() + } else { + !self.partial.is_empty() + } + } + /// The unfinished line, for display below the completed output. pub fn partial_display(&self) -> Option { if self.hex { From 797781325a5b9522b905cc7e4c68e16191d80aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Wed, 9 Sep 2026 23:48:44 +0200 Subject: [PATCH 15/34] fix: retry reopen when the reconnected port cannot be cloned Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/serial_io.rs | 118 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 88 insertions(+), 30 deletions(-) diff --git a/src/serial_io.rs b/src/serial_io.rs index 660dc93..89d04fb 100644 --- a/src/serial_io.rs +++ b/src/serial_io.rs @@ -43,59 +43,83 @@ pub fn spawn_supervisor( mut rx_log: Option, ) -> JoinHandle<()> { std::thread::spawn(move || { - let mut port = Some(first_port); + // A missing write half on the first connection degrades to read-only + let write_half = first_port + .try_clone() + .map_err(|e| { + let _ = events.send(SerialEvent::Error(format!( + "cannot clone port handle, sending disabled: {e}" + ))); + }) + .ok(); + let mut next = Some((first_port, write_half)); let mut reconnected = false; while running.load(Ordering::SeqCst) { - let Some(p) = port.take() else { break }; - - // Hand the writer its own handle to the fresh connection - match p.try_clone() { - Ok(w) => { - if writer.send(WriterMsg::NewPort(w)).is_err() { - break; - } - } - Err(e) => { - let _ = events.send(SerialEvent::Error(format!( - "cannot clone port handle, sending disabled: {e}" - ))); - } + let Some((port, write_half)) = next.take() else { + break; + }; + if let Some(w) = write_half + && writer.send(WriterMsg::NewPort(w)).is_err() + { + break; } // Announce only once the writer can use the new connection if reconnected { let _ = events.send(SerialEvent::Reconnected); - reconnected = false; } - let reader = spawn_reader(p, running.clone(), events.clone(), rx_log.take()); + let reader = spawn_reader(port, running.clone(), events.clone(), rx_log.take()); rx_log = reader.join().unwrap_or(None); if !running.load(Ordering::SeqCst) { break; } // Reader exited while we are still running: the device is gone. - // Poll until the port can be reopened. + // Make the writer drop its handle, then poll until the port is back. let (ack_tx, ack_rx) = std::sync::mpsc::sync_channel(1); if writer.send(WriterMsg::Disconnected(ack_tx)).is_err() { break; } let _ = ack_rx.recv_timeout(DISCONNECT_ACK_TIMEOUT); - let mut ticks = 0u32; - while running.load(Ordering::SeqCst) && port.is_none() { - std::thread::sleep(RECONNECT_POLL); - ticks += 1; - if !ticks.is_multiple_of(RECONNECT_RETRY_TICKS) { - continue; - } - if let Ok(p) = settings.open() { - port = Some(p); - reconnected = true; - } - } + next = wait_for_port(&settings, &running, &events).map(|(p, w)| (p, Some(w))); + reconnected = true; } }) } +/// Poll until the port reopens with a usable write half, or until shutdown. +/// A port that reopens but cannot be cloned is dropped and retried: the +/// device is most likely still enumerating. +fn wait_for_port( + settings: &PortSettings, + running: &AtomicBool, + events: &mpsc::UnboundedSender, +) -> Option<(Box, Box)> { + let mut ticks = 0u32; + let mut clone_warned = false; + while running.load(Ordering::SeqCst) { + std::thread::sleep(RECONNECT_POLL); + ticks += 1; + if !ticks.is_multiple_of(RECONNECT_RETRY_TICKS) { + continue; + } + let Ok(port) = settings.open() else { + continue; + }; + match port.try_clone() { + Ok(write_half) => return Some((port, write_half)), + Err(e) if !clone_warned => { + clone_warned = true; + let _ = events.send(SerialEvent::Error(format!( + "port reopened but handle cannot be cloned, retrying: {e}" + ))); + } + Err(_) => {} + } + } + None +} + /// Reads from the port until shutdown or a fatal error. Returns the RX log /// sink so a future connection can keep appending to it. fn spawn_reader( @@ -179,6 +203,40 @@ pub fn spawn_writer( #[cfg(test)] mod tests { use super::*; + use serialport::{DataBits, FlowControl, Parity, StopBits}; + + fn settings_for(name: String) -> PortSettings { + PortSettings { + name, + baud: 115_200, + data_bits: DataBits::Eight, + parity: Parity::None, + stop_bits: StopBits::One, + flow_control: FlowControl::None, + dtr: None, + rts: None, + } + } + + #[test] + fn wait_for_port_gives_up_on_shutdown() { + let settings = settings_for("/nonexistent/port".to_string()); + let (events, _rx) = mpsc::unbounded_channel(); + assert!(wait_for_port(&settings, &AtomicBool::new(false), &events).is_none()); + } + + #[cfg(unix)] + #[test] + fn wait_for_port_returns_port_with_write_half() { + let (master, slave) = serialport::TTYPort::pair().expect("pty pair"); + let settings = settings_for(slave.name().expect("pty slave has a path")); + drop(slave); + let (events, _rx) = mpsc::unbounded_channel(); + + let reopened = wait_for_port(&settings, &AtomicBool::new(true), &events); + assert!(reopened.is_some(), "pty slave must be reopenable"); + drop(master); + } #[test] fn writer_reports_not_connected_after_disconnect() { From a95884f7c51c965ecd9069b8163927ce4a3f315a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:09:19 +0200 Subject: [PATCH 16/34] fix: show timestamps in local time instead of UTC Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/logging.rs | 8 ++++++-- src/ui/line_assembler.rs | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/logging.rs b/src/logging.rs index 916bb36..fb8d5ad 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use chrono::Utc; +use chrono::Local; use std::fs::{File, OpenOptions}; use std::io::{self, BufWriter, Write}; use std::path::Path; @@ -50,7 +50,11 @@ impl LogSink { } fn write_timestamp(writer: &mut W) -> io::Result<()> { - write!(writer, "[{}] ", Utc::now().format("%Y-%m-%d %H:%M:%S%.3f")) + write!( + writer, + "[{}] ", + Local::now().format("%Y-%m-%d %H:%M:%S%.3f") + ) } fn write_text(&mut self, bytes: &[u8]) -> io::Result<()> { diff --git a/src/ui/line_assembler.rs b/src/ui/line_assembler.rs index 2c5939c..b179be2 100644 --- a/src/ui/line_assembler.rs +++ b/src/ui/line_assembler.rs @@ -1,4 +1,4 @@ -use chrono::Utc; +use chrono::Local; use std::fmt::Write as _; const HEX_BYTES_PER_LINE: usize = 16; @@ -201,7 +201,7 @@ impl LineAssembler { } fn timestamp() -> String { - format!("[{}] ", Utc::now().format("%Y-%m-%d %H:%M:%S%.3f")) + format!("[{}] ", Local::now().format("%Y-%m-%d %H:%M:%S%.3f")) } /// Decode the longest UTF-8 prefix, hiding an incomplete trailing sequence From d36192c6f3b96d3a1442a7246af1339856a12f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:09:21 +0200 Subject: [PATCH 17/34] feat: rename --log-ts to --timestamps and keep the old name as alias Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 2 +- src/main.rs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b9f6e71..667b19e 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Options: --raw Keep ANSI escape sequences instead of stripping them --log Log received data --tx-log Log transmitted data - --log-ts Add timestamps to logs and display + -t, --timestamps Add timestamps to display and logs (alias: --log-ts) --list List available ports ``` diff --git a/src/main.rs b/src/main.rs index 2a2ea60..9ffde77 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,9 +68,9 @@ struct Args { #[arg(long)] tx_log: Option, - /// Prepend timestamps to logged and displayed lines - #[arg(long = "log-ts")] - log_ts: bool, + /// Prepend timestamps to displayed and logged lines + #[arg(short = 't', long, alias = "log-ts")] + timestamps: bool, /// Show RX as hex (space-separated bytes) #[arg(long)] @@ -131,8 +131,8 @@ async fn main() -> Result<()> { if args.raw { println!("ANSI escapes: kept"); } - if args.log_ts { - println!("Timestamps in logs: ON"); + if args.timestamps { + println!("Timestamps: ON"); } // Open port @@ -156,12 +156,12 @@ async fn main() -> Result<()> { let rx_log = args .log .as_deref() - .map(|p| LogSink::open(p, "RX", args.log_ts, args.hex)) + .map(|p| LogSink::open(p, "RX", args.timestamps, args.hex)) .transpose()?; let tx_log = args .tx_log .as_deref() - .map(|p| LogSink::open(p, "TX", args.log_ts, false)) + .map(|p| LogSink::open(p, "TX", args.timestamps, false)) .transpose()?; // Handle Ctrl-C with immediate shutdown @@ -216,7 +216,7 @@ async fn main() -> Result<()> { line_ending, writer: writer_tx.clone(), hex: args.hex, - show_ts: args.log_ts, + show_ts: args.timestamps, raw: args.raw, port_label: format!("{port_name} @ {baud}"), }; From 3b5a97c34e7036eaec06c2cbcbdc4b03c77aeb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:09:23 +0200 Subject: [PATCH 18/34] feat: accept lf as alias for the nl line ending Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 2 +- src/config.rs | 1 + src/main.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 667b19e..8cfabcf 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ sermonizer [OPTIONS] Options: -p, --port Serial port path -b, --baud Baud rate (default: 115200) - --line-ending Line ending: none|nl|cr|crlf (default: nl) + --line-ending Line ending: none|nl|lf|cr|crlf (default: nl) --data-bits Data bits: 5|6|7|8 (default: 8) --parity

Parity: none|odd|even (default: none) --stop-bits Stop bits: 1|2 (default: 1) diff --git a/src/config.rs b/src/config.rs index d563b60..2fa42bc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -180,6 +180,7 @@ pub enum LineEnding { /// Send nothing extra (no line ending) None, /// Send '\n' (LF) + #[value(alias = "lf")] Nl, /// Send '\r' (CR) Cr, diff --git a/src/main.rs b/src/main.rs index 9ffde77..c0057b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,7 +32,7 @@ struct Args { #[arg(short = 'b', long, default_value_t = 115_200)] baud: u32, - /// Line ending when you press Enter (none|nl|cr|crlf). Default: nl + /// Line ending when you press Enter (none|nl|cr|crlf; lf is an alias for nl). Default: nl #[arg(long, value_enum)] line_ending: Option, From b47352d3898ed575df8f8ec469c1028ed3ab27fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:10:06 +0200 Subject: [PATCH 19/34] fix: send pasted text line by line and accept Ctrl+J as Enter Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 1 + src/main.rs | 6 ++++++ src/ui/mod.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/README.md b/README.md index 8cfabcf..c9c72f2 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Options: ## Controls - **Type and press Enter**: Send data to device +- **Paste**: Multi-line text is sent line by line; an unfinished last line stays in the input box - **↑↓**: Browse send history - **Shift+↑↓ / Page Up/Down**: Scroll through output - **Home / End**: Jump to top / bottom (End resumes auto-scroll) diff --git a/src/main.rs b/src/main.rs index c0057b7..11f4b61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,8 @@ use config::{ }; use logging::LogSink; use port_discovery::{choose_port_interactive, get_available_ports, print_ports}; +use ratatui::crossterm::event::{DisableBracketedPaste, EnableBracketedPaste}; +use ratatui::crossterm::execute; use serial_io::{SerialEvent, WriterMsg, spawn_supervisor, spawn_writer}; use std::path::PathBuf; use std::sync::{ @@ -210,6 +212,9 @@ async fn main() -> Result<()> { let _ = ratatui::try_restore(); }) .context("Failed to set up terminal")?; + // Best effort: terminals without bracketed paste still deliver pasted + // text as key events + let _ = execute!(std::io::stdout(), EnableBracketedPaste); let ui_config = UiConfig { running: running.clone(), @@ -224,6 +229,7 @@ async fn main() -> Result<()> { let ui_res = run_ui(&mut terminal, ui_rx, event_rx, ui_config).await; // Restore terminal before anything else can fail + let _ = execute!(std::io::stdout(), DisableBracketedPaste); ratatui::try_restore().context("Failed to restore terminal")?; terminal.show_cursor()?; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index cba2db6..f99f56c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -117,11 +117,29 @@ fn handle_input_event(event: Event, app_state: &mut AppState, ui_config: &UiConf Event::Key(k) if k.kind == KeyEventKind::Press => { handle_key_event(k, app_state, ui_config); } + Event::Paste(text) => handle_paste(&text, app_state, ui_config), Event::Resize(_, _) => app_state.needs_render = true, _ => {} } } +/// Pasted text is sent line by line; an unterminated last line stays in the +/// input box so the user can finish it. +fn handle_paste(text: &str, app_state: &mut AppState, ui_config: &UiConfig) { + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\r' => { + chars.next_if_eq(&'\n'); + handle_enter_key(app_state, ui_config); + } + '\n' => handle_enter_key(app_state, ui_config), + c if c.is_control() => {} + c => app_state.update_input(c), + } + } +} + fn handle_serial_event(event: SerialEvent, app_state: &mut AppState) { match event { SerialEvent::Data(bytes) => { @@ -175,6 +193,10 @@ fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfi KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => { app_state.clear_output(); } + // A pasted LF arrives as Ctrl+J in raw mode + KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { + handle_enter_key(app_state, ui_config); + } KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => { app_state.pending_literal = true; app_state.needs_render = true; @@ -282,6 +304,40 @@ mod tests { (config, writer_rx) } + #[test] + fn ctrl_j_sends_like_enter() { + let (config, writer_rx) = test_config(); + let mut state = AppState::new(false, false, true, String::new(), "LF"); + handle_key_event(KeyEvent::from(KeyCode::Char('a')), &mut state, &config); + handle_key_event( + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), + &mut state, + &config, + ); + match writer_rx.try_recv() { + Ok(WriterMsg::Data(bytes)) => assert_eq!(bytes, b"a\n"), + _ => panic!("expected the line to be sent"), + } + assert!(state.input_line.is_empty()); + } + + #[test] + fn paste_sends_complete_lines_and_keeps_the_rest() { + let (config, writer_rx) = test_config(); + let mut state = AppState::new(false, false, true, String::new(), "LF"); + handle_input_event( + Event::Paste("first\r\nsecond\nthird".to_string()), + &mut state, + &config, + ); + let mut sent = Vec::new(); + while let Ok(WriterMsg::Data(bytes)) = writer_rx.try_recv() { + sent.push(bytes); + } + assert_eq!(sent, vec![b"first\n".to_vec(), b"second\n".to_vec()]); + assert_eq!(state.input_line, "third"); + } + #[test] fn literal_mode_reports_keys_without_a_mapping() { let (config, writer_rx) = test_config(); From 168eea4267c35f60f2b727eaaa6438ebdca894d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:10:45 +0200 Subject: [PATCH 20/34] feat: readline-style editing keys in the input line Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 4 +- src/ui/app_state.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 33 +++++++++++++++- src/ui/rendering.rs | 2 +- 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c9c72f2..d314757 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,10 @@ Options: - **Type and press Enter**: Send data to device - **Paste**: Multi-line text is sent line by line; an unfinished last line stays in the input box - **↑↓**: Browse send history +- **Home / End, Ctrl+A / Ctrl+E**: Jump to start / end of the input line +- **Ctrl+U / Ctrl+K / Ctrl+W**: Delete to start of line / to end of line / previous word - **Shift+↑↓ / Page Up/Down**: Scroll through output -- **Home / End**: Jump to top / bottom (End resumes auto-scroll) +- **Shift+Home / Shift+End** (or Ctrl+Home / Ctrl+End): Jump to top / bottom of output (End resumes auto-scroll) - **Ctrl+L**: Clear output - **Ctrl+V, then a key**: Send that key as a raw control byte (e.g. Ctrl+V Ctrl+C sends 0x03) - **Esc**: Clear input line diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 334bd4d..1857c3c 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -281,6 +281,56 @@ impl AppState { } } + pub fn input_home(&mut self) { + if self.input_cursor != 0 { + self.input_cursor = 0; + self.needs_render = true; + } + } + + pub fn input_end(&mut self) { + let end = self.input_line.chars().count(); + if self.input_cursor != end { + self.input_cursor = end; + self.needs_render = true; + } + } + + pub fn kill_to_start(&mut self) { + let byte_idx = self.input_byte_index(self.input_cursor); + if byte_idx > 0 { + self.input_line.drain(..byte_idx); + self.input_cursor = 0; + self.needs_render = true; + } + } + + pub fn kill_to_end(&mut self) { + let byte_idx = self.input_byte_index(self.input_cursor); + if byte_idx < self.input_line.len() { + self.input_line.truncate(byte_idx); + self.needs_render = true; + } + } + + /// Delete back to the start of the previous word, like readline's Ctrl+W + pub fn delete_word_back(&mut self) { + let end = self.input_byte_index(self.input_cursor); + let head = &self.input_line[..end]; + let trimmed = head.trim_end_matches(char::is_whitespace); + let start = trimmed + .char_indices() + .rev() + .find(|(_, c)| c.is_whitespace()) + .map_or(0, |(i, c)| i + c.len_utf8()); + if start < end { + let removed = self.input_line[start..end].chars().count(); + self.input_line.drain(start..end); + self.input_cursor -= removed; + self.needs_render = true; + } + } + pub fn clear_input(&mut self) -> String { self.input_cursor = 0; let input = std::mem::take(&mut self.input_line); @@ -366,6 +416,51 @@ mod tests { assert_eq!(state.list_state.selected(), Some(40)); } + fn state_with_input(text: &str) -> AppState { + let mut state = AppState::new(false, false, true, String::new(), "LF"); + for c in text.chars() { + state.update_input(c); + } + state + } + + #[test] + fn home_and_end_move_the_input_cursor() { + let mut state = state_with_input("grΓΌn ok"); + state.input_home(); + assert_eq!(state.input_cursor, 0); + state.update_input('>'); + assert_eq!(state.input_line, ">grΓΌn ok"); + state.input_end(); + state.update_input('<'); + assert_eq!(state.input_line, ">grΓΌn ok<"); + } + + #[test] + fn kill_to_start_and_end_split_at_the_cursor() { + let mut state = state_with_input("abcdef"); + state.move_cursor_left(); + state.move_cursor_left(); + state.kill_to_end(); + assert_eq!(state.input_line, "abcd"); + assert_eq!(state.input_cursor, 4); + state.move_cursor_left(); + state.kill_to_start(); + assert_eq!(state.input_line, "d"); + assert_eq!(state.input_cursor, 0); + } + + #[test] + fn delete_word_back_removes_trailing_spaces_and_one_word() { + let mut state = state_with_input("AT+CWJAP ssid "); + state.delete_word_back(); + assert_eq!(state.input_line, "AT+CWJAP "); + state.delete_word_back(); + assert_eq!(state.input_line, ""); + state.delete_word_back(); + assert_eq!(state.input_line, ""); + } + #[test] fn scroll_up_then_down_moves_relative_to_bottom() { let mut state = state_with_lines(50); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f99f56c..29f4a60 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -201,6 +201,21 @@ fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfi app_state.pending_literal = true; app_state.needs_render = true; } + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app_state.input_home(); + } + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app_state.input_end(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app_state.kill_to_start(); + } + KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app_state.kill_to_end(); + } + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app_state.delete_word_back(); + } KeyCode::Esc => { app_state.clear_input(); } @@ -244,12 +259,26 @@ fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfi KeyCode::PageDown => { app_state.scroll_page_down(10); } - KeyCode::Home => { + KeyCode::Home + if key + .modifiers + .intersects(KeyModifiers::SHIFT | KeyModifiers::CONTROL) => + { app_state.scroll_to_home(); } - KeyCode::End => { + KeyCode::End + if key + .modifiers + .intersects(KeyModifiers::SHIFT | KeyModifiers::CONTROL) => + { app_state.scroll_to_bottom(); } + KeyCode::Home => { + app_state.input_home(); + } + KeyCode::End => { + app_state.input_end(); + } _ => {} } } diff --git a/src/ui/rendering.rs b/src/ui/rendering.rs index 8f7cad6..837c133 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -114,7 +114,7 @@ fn status_line(app_state: &AppState) -> Paragraph<'_> { )); } else { spans.push(Span::styled( - " | Enter send Β· ↑↓ history Β· Shift+↑↓/PgUp/PgDn scroll Β· End follow Β· Ctrl+L clear Β· Ctrl+V literal Β· Ctrl+C quit", + " | Enter send Β· ↑↓ history Β· Shift+↑↓/PgUp/PgDn scroll Β· Shift+End follow Β· Ctrl+L clear Β· Ctrl+V literal Β· Ctrl+C quit", Style::default().fg(Color::DarkGray), )); } From 46d2c0ac93892e7199d5ea26662030cd271dd3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:12:05 +0200 Subject: [PATCH 21/34] refactor: tag output lines with their origin and style TX and notices Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/app_state.rs | 38 +++++++++++++++++++++++++++++++++++--- src/ui/mod.rs | 17 ++++++++++------- src/ui/rendering.rs | 24 +++++++++++++++++++++--- 3 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 1857c3c..dc6dbf7 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -5,6 +5,20 @@ use super::line_assembler::LineAssembler; const MAX_OUTPUT_LINES: usize = 1000; +/// Origin of an output line, used to style it +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineKind { + Rx, + Tx, + Notice, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutputLine { + pub kind: LineKind, + pub text: String, +} + pub struct AppState { pub input_line: String, pub input_cursor: usize, // Cursor position as char index into input_line @@ -12,7 +26,7 @@ pub struct AppState { pub history_pos: Option, pub draft: String, // Unsent input stashed while browsing history pub pending_literal: bool, // Next key is sent as a raw control byte - pub output_lines: VecDeque, + pub output_lines: VecDeque, pub assembler: LineAssembler, pub list_state: ListState, pub auto_scroll_state: ListState, @@ -59,7 +73,11 @@ impl AppState { if !self.auto_scroll { self.unseen_lines += completed.len(); } - self.output_lines.extend(completed); + self.output_lines + .extend(completed.into_iter().map(|text| OutputLine { + kind: LineKind::Rx, + text, + })); self.trim_output(); // The partial line is displayed too, so any data changes the view self.needs_render = true; @@ -67,10 +85,24 @@ impl AppState { /// Push a complete status line (bypasses line assembly). pub fn add_notice(&mut self, message: String) { + self.push_line(LineKind::Notice, message); + } + + /// Push a complete received line that bypassed line assembly. + pub fn add_rx(&mut self, text: String) { + self.push_line(LineKind::Rx, text); + } + + /// Push a line describing data that was just transmitted. + pub fn add_tx(&mut self, text: String) { + self.push_line(LineKind::Tx, text); + } + + fn push_line(&mut self, kind: LineKind, text: String) { if !self.auto_scroll { self.unseen_lines += 1; } - self.output_lines.push_back(message); + self.output_lines.push_back(OutputLine { kind, text }); self.trim_output(); self.needs_render = true; } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 29f4a60..17c9da2 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -150,7 +150,7 @@ fn handle_serial_event(event: SerialEvent, app_state: &mut AppState) { } SerialEvent::Disconnected(reason) => { if let Some(line) = app_state.assembler.finish() { - app_state.add_notice(line); + app_state.add_rx(line); } app_state.set_connected(false); app_state.add_notice(format!( @@ -179,7 +179,7 @@ fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfi if ui_config.writer.send(WriterMsg::Data(vec![byte])).is_err() { app_state.add_notice("[sermonizer] writer stopped, input dropped".to_string()); } else { - app_state.add_notice(format!("[sermonizer] sent control byte 0x{byte:02X}")); + app_state.add_tx(format!("<0x{byte:02X}>")); } return; } @@ -318,6 +318,7 @@ fn handle_enter_key(app_state: &mut AppState, ui_config: &UiConfig) { mod tests { use super::*; use crate::config::LineEnding; + use app_state::LineKind; fn test_config() -> (UiConfig, std::sync::mpsc::Receiver) { let (writer, writer_rx) = std::sync::mpsc::channel(); @@ -381,7 +382,7 @@ mod tests { assert!(!state.pending_literal); assert!(writer_rx.try_recv().is_err(), "nothing must be sent"); assert!( - state.output_lines[0].contains("nothing sent"), + state.output_lines[0].text.contains("nothing sent"), "{:?}", state.output_lines ); @@ -396,22 +397,24 @@ mod tests { assert!(!state.connected); assert_eq!(state.assembler.partial_display(), None); assert_eq!( - state.output_lines[0], + state.output_lines[0].text, if hex { "62 65 66 6F 72 65" } else { "before" } ); - assert!(state.output_lines[1].contains("device disconnected")); + assert_eq!(state.output_lines[0].kind, LineKind::Rx); + assert!(state.output_lines[1].text.contains("device disconnected")); + assert_eq!(state.output_lines[1].kind, LineKind::Notice); handle_serial_event(SerialEvent::Reconnected, &mut state); handle_serial_event(SerialEvent::Data(b"after\n".to_vec()), &mut state); assert!(state.connected); - assert!(state.output_lines[2].contains("device reconnected")); + assert!(state.output_lines[2].text.contains("device reconnected")); if hex { assert_eq!( state.assembler.partial_display().as_deref(), Some("61 66 74 65 72 0A") ); } else { - assert_eq!(state.output_lines[3], "after"); + assert_eq!(state.output_lines[3].text, "after"); } } } diff --git a/src/ui/rendering.rs b/src/ui/rendering.rs index 837c133..8c5c51f 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -1,13 +1,15 @@ -use super::app_state::AppState; +use super::app_state::{AppState, LineKind, OutputLine}; use ratatui::{ Frame, layout::{Constraint, Direction, Layout}, - style::{Color, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, Paragraph}, }; use unicode_width::UnicodeWidthChar; +const TX_PREFIX: &str = "> "; + pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { let chunks = Layout::default() .direction(Direction::Vertical) @@ -22,7 +24,7 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { let mut output_items: Vec = app_state .output_lines .iter() - .map(|line| ListItem::new(line.as_str())) + .map(|line| ListItem::new(output_line(line))) .collect(); // Show the line still being received below the completed output @@ -81,6 +83,22 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { f.render_widget(status_line(app_state), chunks[2]); } +fn output_line(line: &OutputLine) -> Line<'_> { + match line.kind { + LineKind::Rx => Line::raw(line.text.as_str()), + LineKind::Tx => Line::from(vec![ + Span::styled(TX_PREFIX, Style::default().fg(Color::Cyan)), + Span::styled(line.text.as_str(), Style::default().fg(Color::Cyan)), + ]), + LineKind::Notice => Line::styled( + line.text.as_str(), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::ITALIC), + ), + } +} + fn status_line(app_state: &AppState) -> Paragraph<'_> { let mut spans: Vec = Vec::new(); From 86a5b398c09582bd7a42974bc259e9e381061c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:12:07 +0200 Subject: [PATCH 22/34] feat: add --echo to show transmitted lines in the output Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 2 ++ src/config.rs | 2 ++ src/main.rs | 8 ++++++++ src/ui/app_state.rs | 9 ++++++++- src/ui/line_assembler.rs | 2 +- src/ui/mod.rs | 26 +++++++++++++++++++++++++- 6 files changed, 46 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d314757..279e129 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Most terminal-based serial monitors are annoying to use - they have clunky inter - **Sane defaults**: 115200 baud, 8 data bits, no parity, 1 stop bit - **Hex mode**: View binary data as hex bytes - **Clean text**: ANSI colour codes from firmware logs are stripped (keep them with `--raw`) +- **Local echo**: `--echo` shows what you sent, for devices that do not echo - **Logging**: Save RX/TX data with timestamps - **Fast**: Built in Rust, handles high baud rates smoothly @@ -67,6 +68,7 @@ Options: --rts Set the RTS line after opening --hex Display data as hex --raw Keep ANSI escape sequences instead of stripping them + -e, --echo Show sent lines in the output, prefixed with "> " --log Log received data --tx-log Log transmitted data -t, --timestamps Add timestamps to display and logs (alias: --log-ts) diff --git a/src/config.rs b/src/config.rs index 2fa42bc..2388b7e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -216,5 +216,7 @@ pub struct UiConfig { pub show_ts: bool, /// Keep ANSI escape sequences in the display instead of stripping them pub raw: bool, + /// Show transmitted lines in the output + pub echo: bool, pub port_label: String, } diff --git a/src/main.rs b/src/main.rs index 11f4b61..0f9d8a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,6 +82,10 @@ struct Args { #[arg(long)] raw: bool, + /// Show what you send in the output, prefixed with "> " + #[arg(short = 'e', long)] + echo: bool, + /// Just list ports and exit #[arg(long)] list: bool, @@ -133,6 +137,9 @@ async fn main() -> Result<()> { if args.raw { println!("ANSI escapes: kept"); } + if args.echo { + println!("Local echo: ON"); + } if args.timestamps { println!("Timestamps: ON"); } @@ -223,6 +230,7 @@ async fn main() -> Result<()> { hex: args.hex, show_ts: args.timestamps, raw: args.raw, + echo: args.echo, port_label: format!("{port_name} @ {baud}"), }; diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index dc6dbf7..cd3cc4d 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -1,7 +1,7 @@ use ratatui::widgets::ListState; use std::collections::VecDeque; -use super::line_assembler::LineAssembler; +use super::line_assembler::{LineAssembler, timestamp}; const MAX_OUTPUT_LINES: usize = 1000; @@ -34,6 +34,7 @@ pub struct AppState { pub auto_scroll: bool, pub connected: bool, pub unseen_lines: usize, // Lines received while not following the output + pub show_ts: bool, pub port_label: String, pub line_ending_label: &'static str, pub needs_render: bool, // Optimization: only render when needed @@ -62,6 +63,7 @@ impl AppState { auto_scroll: true, connected: true, unseen_lines: 0, + show_ts: timestamps, port_label, line_ending_label, needs_render: true, @@ -95,6 +97,11 @@ impl AppState { /// Push a line describing data that was just transmitted. pub fn add_tx(&mut self, text: String) { + let text = if self.show_ts { + format!("{}{text}", timestamp()) + } else { + text + }; self.push_line(LineKind::Tx, text); } diff --git a/src/ui/line_assembler.rs b/src/ui/line_assembler.rs index b179be2..8497240 100644 --- a/src/ui/line_assembler.rs +++ b/src/ui/line_assembler.rs @@ -200,7 +200,7 @@ impl LineAssembler { } } -fn timestamp() -> String { +pub fn timestamp() -> String { format!("[{}] ", Local::now().format("%Y-%m-%d %H:%M:%S%.3f")) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 17c9da2..124521d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -303,7 +303,7 @@ fn handle_enter_key(app_state: &mut AppState, ui_config: &UiConfig) { app_state.push_history(input.clone()); // Send input and line ending as a single write - let mut bytes = input.into_bytes(); + let mut bytes = input.clone().into_bytes(); bytes.extend_from_slice(ui_config.line_ending.bytes()); if bytes.is_empty() { return; @@ -311,6 +311,8 @@ fn handle_enter_key(app_state: &mut AppState, ui_config: &UiConfig) { if ui_config.writer.send(WriterMsg::Data(bytes)).is_err() { app_state.add_notice("[sermonizer] writer stopped, input dropped".to_string()); + } else if ui_config.echo { + app_state.add_tx(input); } } @@ -329,11 +331,33 @@ mod tests { hex: false, show_ts: false, raw: false, + echo: false, port_label: String::new(), }; (config, writer_rx) } + #[test] + fn echo_shows_sent_lines_as_tx() { + let (mut config, _writer_rx) = test_config(); + config.echo = true; + let mut state = AppState::new(false, false, true, String::new(), "LF"); + handle_key_event(KeyEvent::from(KeyCode::Char('h')), &mut state, &config); + handle_key_event(KeyEvent::from(KeyCode::Char('i')), &mut state, &config); + handle_key_event(KeyEvent::from(KeyCode::Enter), &mut state, &config); + assert_eq!(state.output_lines[0].kind, LineKind::Tx); + assert_eq!(state.output_lines[0].text, "hi"); + } + + #[test] + fn without_echo_sent_lines_are_not_shown() { + let (config, _writer_rx) = test_config(); + let mut state = AppState::new(false, false, true, String::new(), "LF"); + handle_key_event(KeyEvent::from(KeyCode::Char('h')), &mut state, &config); + handle_key_event(KeyEvent::from(KeyCode::Enter), &mut state, &config); + assert!(state.output_lines.is_empty()); + } + #[test] fn ctrl_j_sends_like_enter() { let (config, writer_rx) = test_config(); From efa3cd213a90d08939a39cc847527460951aed0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:12:10 +0200 Subject: [PATCH 23/34] fix: keep typed input when sending while disconnected Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/mod.rs | 55 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 124521d..dbd3fbb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -129,11 +129,16 @@ fn handle_paste(text: &str, app_state: &mut AppState, ui_config: &UiConfig) { let mut chars = text.chars().peekable(); while let Some(c) = chars.next() { match c { - '\r' => { - chars.next_if_eq(&'\n'); - handle_enter_key(app_state, ui_config); + '\r' | '\n' => { + if c == '\r' { + chars.next_if_eq(&'\n'); + } + // Stop at the first line that cannot be sent so the paste + // does not run together into one input line + if !handle_enter_key(app_state, ui_config) { + break; + } } - '\n' => handle_enter_key(app_state, ui_config), c if c.is_control() => {} c => app_state.update_input(c), } @@ -169,6 +174,10 @@ fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfi if app_state.pending_literal { app_state.pending_literal = false; app_state.needs_render = true; + if !app_state.connected { + app_state.add_notice("[sermonizer] not connected, nothing sent".to_string()); + return; + } let Some(byte) = literal_byte(key) else { app_state.add_notice( "[sermonizer] no literal byte for that key, nothing sent (use Ctrl+A..Z, Esc, Enter or Tab)" @@ -195,7 +204,7 @@ fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfi } // A pasted LF arrives as Ctrl+J in raw mode KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { - handle_enter_key(app_state, ui_config); + let _ = handle_enter_key(app_state, ui_config); } KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => { app_state.pending_literal = true; @@ -227,7 +236,7 @@ fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfi app_state.update_input(c); } KeyCode::Enter => { - handle_enter_key(app_state, ui_config); + let _ = handle_enter_key(app_state, ui_config); } KeyCode::Backspace => { app_state.backspace_input(); @@ -298,7 +307,15 @@ fn literal_byte(key: KeyEvent) -> Option { } } -fn handle_enter_key(app_state: &mut AppState, ui_config: &UiConfig) { +/// Returns whether the line was handed to the writer. +fn handle_enter_key(app_state: &mut AppState, ui_config: &UiConfig) -> bool { + if !app_state.connected { + app_state.add_notice( + "[sermonizer] not connected, input kept: press Enter again once the device is back" + .to_string(), + ); + return false; + } let input = app_state.clear_input(); app_state.push_history(input.clone()); @@ -306,14 +323,17 @@ fn handle_enter_key(app_state: &mut AppState, ui_config: &UiConfig) { let mut bytes = input.clone().into_bytes(); bytes.extend_from_slice(ui_config.line_ending.bytes()); if bytes.is_empty() { - return; + return true; } if ui_config.writer.send(WriterMsg::Data(bytes)).is_err() { app_state.add_notice("[sermonizer] writer stopped, input dropped".to_string()); - } else if ui_config.echo { + return false; + } + if ui_config.echo { app_state.add_tx(input); } + true } #[cfg(test)] @@ -358,6 +378,23 @@ mod tests { assert!(state.output_lines.is_empty()); } + #[test] + fn enter_while_disconnected_keeps_the_input() { + let (config, writer_rx) = test_config(); + let mut state = AppState::new(false, false, true, String::new(), "LF"); + state.set_connected(false); + handle_key_event(KeyEvent::from(KeyCode::Char('x')), &mut state, &config); + handle_key_event(KeyEvent::from(KeyCode::Enter), &mut state, &config); + assert_eq!(state.input_line, "x"); + assert!(writer_rx.try_recv().is_err()); + assert!(state.output_lines[0].text.contains("not connected")); + + state.set_connected(true); + handle_key_event(KeyEvent::from(KeyCode::Enter), &mut state, &config); + assert!(state.input_line.is_empty()); + assert!(matches!(writer_rx.try_recv(), Ok(WriterMsg::Data(b)) if b == b"x\n")); + } + #[test] fn ctrl_j_sends_like_enter() { let (config, writer_rx) = test_config(); From fb86de3e7d9d775109bd9d4f6ca4f0254025ea5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:13:54 +0200 Subject: [PATCH 24/34] fix: keep the status bar readable with long port paths Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/config.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 14 +++++++++----- src/ui/mod.rs | 2 +- src/ui/rendering.rs | 14 ++++++++------ 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/config.rs b/src/config.rs index 2388b7e..2fc0f35 100644 --- a/src/config.rs +++ b/src/config.rs @@ -208,6 +208,27 @@ impl LineEnding { } } +/// Longest port name shown in the status bar before it is cut from the left +const PORT_LABEL_MAX: usize = 28; + +/// Compact status-bar label: the port's basename (by-id paths and Windows +/// COM names both survive), cut from the left when still too long, followed +/// by baud and framing. +pub fn port_label(port_name: &str, baud: u32, framing: &str) -> String { + let name = std::path::Path::new(port_name) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(port_name); + let chars = name.chars().count(); + let name = if chars > PORT_LABEL_MAX { + let tail: String = name.chars().skip(chars - (PORT_LABEL_MAX - 3)).collect(); + format!("...{tail}") + } else { + name.to_string() + }; + format!("{name} {baud} {framing}") +} + pub struct UiConfig { pub running: Arc, pub line_ending: LineEnding, @@ -220,3 +241,25 @@ pub struct UiConfig { pub echo: bool, pub port_label: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn port_label_uses_the_basename_with_baud_and_framing() { + assert_eq!( + port_label("/dev/ttyUSB0", 115_200, "8N1"), + "ttyUSB0 115200 8N1" + ); + assert_eq!(port_label("COM3", 9600, "7E1"), "COM3 9600 7E1"); + } + + #[test] + fn port_label_cuts_long_names_from_the_left() { + let name = "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0"; + let label = port_label(name, 115_200, "8N1"); + assert_eq!(label, "...ontroller_0001-if00-port0 115200 8N1"); + assert!(label.len() <= PORT_LABEL_MAX + " 115200 8N1".len()); + } +} diff --git a/src/main.rs b/src/main.rs index 0f9d8a1..b85ea8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,8 @@ mod ui; use anyhow::{Context, Result}; use clap::Parser; use config::{ - DataBitsArg, FlowControlArg, LineEnding, ParityArg, PortSettings, StopBitsArg, Toggle, UiConfig, + DataBitsArg, FlowControlArg, LineEnding, ParityArg, PortSettings, StopBitsArg, Toggle, + UiConfig, port_label, }; use logging::LogSink; use port_discovery::{choose_port_interactive, get_available_ports, print_ports}; @@ -115,11 +116,14 @@ async fn main() -> Result<()> { // Decide on baud let baud = args.baud; println!("Baud: {baud}"); - println!( - "Framing: {}{}{}, flow control: {}", + let framing = format!( + "{}{}{}", args.data_bits.label(), args.parity.label(), - args.stop_bits.label(), + args.stop_bits.label() + ); + println!( + "Framing: {framing}, flow control: {}", args.flow_control.label() ); @@ -231,7 +235,7 @@ async fn main() -> Result<()> { show_ts: args.timestamps, raw: args.raw, echo: args.echo, - port_label: format!("{port_name} @ {baud}"), + port_label: port_label(&port_name, baud, &framing), }; let ui_res = run_ui(&mut terminal, ui_rx, event_rx, ui_config).await; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index dbd3fbb..03dbaec 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -159,7 +159,7 @@ fn handle_serial_event(event: SerialEvent, app_state: &mut AppState) { } app_state.set_connected(false); app_state.add_notice(format!( - "[sermonizer] device disconnected: {reason} β€” reconnecting (Ctrl+C to quit)" + "[sermonizer] device disconnected: {reason} - reconnecting (Ctrl+C to quit)" )); } SerialEvent::Reconnected => { diff --git a/src/ui/rendering.rs b/src/ui/rendering.rs index 8c5c51f..43d5a80 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -102,6 +102,8 @@ fn output_line(line: &OutputLine) -> Line<'_> { fn status_line(app_state: &AppState) -> Paragraph<'_> { let mut spans: Vec = Vec::new(); + // Segments are ordered by importance: the bar is clipped from the right + // on narrow terminals, so key hints go last if app_state.connected { spans.push(Span::styled( format!(" {} ", app_state.port_label), @@ -109,22 +111,22 @@ fn status_line(app_state: &AppState) -> Paragraph<'_> { )); } else { spans.push(Span::styled( - " DISCONNECTED - reconnecting… ", + " DISCONNECTED - reconnecting... ", Style::default().fg(Color::White).bg(Color::Red), )); } - spans.push(Span::raw(format!(" {} | ", app_state.line_ending_label))); - if app_state.auto_scroll { - spans.push(Span::raw("follow")); + spans.push(Span::raw(" follow")); } else { spans.push(Span::styled( - format!("scroll ({} new)", app_state.unseen_lines), + format!(" scroll ({} new)", app_state.unseen_lines), Style::default().fg(Color::Yellow), )); } + spans.push(Span::raw(format!(" | {}", app_state.line_ending_label))); + if app_state.pending_literal { spans.push(Span::styled( " | Ctrl+V: next key is sent raw", @@ -132,7 +134,7 @@ fn status_line(app_state: &AppState) -> Paragraph<'_> { )); } else { spans.push(Span::styled( - " | Enter send Β· ↑↓ history Β· Shift+↑↓/PgUp/PgDn scroll Β· Shift+End follow Β· Ctrl+L clear Β· Ctrl+V literal Β· Ctrl+C quit", + " | Enter send, Up/Down history, Shift+Up/Down PgUp/PgDn scroll, Shift+End follow, Ctrl+L clear, Ctrl+V literal, Ctrl+C quit", Style::default().fg(Color::DarkGray), )); } From 746f96305656a227f6e2ac7a490a7cffde69965a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:15:53 +0200 Subject: [PATCH 25/34] fix: scroll the output viewport and page by the visible height Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/ui/app_state.rs | 253 +++++++++++++++++++++++--------------------- src/ui/mod.rs | 4 +- src/ui/rendering.rs | 14 +-- 3 files changed, 141 insertions(+), 130 deletions(-) diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index cd3cc4d..4475f56 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -28,8 +28,10 @@ pub struct AppState { pub pending_literal: bool, // Next key is sent as a raw control byte pub output_lines: VecDeque, pub assembler: LineAssembler, - pub list_state: ListState, pub auto_scroll_state: ListState, + pub scroll_top: usize, // First visible row while scrolled manually + pub follow_top: usize, // First visible row of the last frame while following + pub view_height: usize, // Rows available to the output list in the last frame pub should_quit: bool, pub auto_scroll: bool, pub connected: bool, @@ -57,8 +59,10 @@ impl AppState { pending_literal: false, output_lines: VecDeque::with_capacity(MAX_OUTPUT_LINES), assembler: LineAssembler::new(hex, timestamps, strip_ansi), - list_state: ListState::default(), auto_scroll_state: ListState::default(), + scroll_top: 0, + follow_top: 0, + view_height: 0, should_quit: false, auto_scroll: true, connected: true, @@ -125,118 +129,91 @@ impl AppState { return; } self.output_lines.drain(..overflow); - // Keep the manual scroll window anchored to the same lines while - // old lines are pruned from the front - if let Some(selected) = self.list_state.selected() { - self.list_state - .select(Some(selected.saturating_sub(overflow))); - } - let offset = self.list_state.offset(); - *self.list_state.offset_mut() = offset.saturating_sub(overflow); + // Keep the scroll window anchored to the same lines while old lines + // are pruned from the front + self.scroll_top = self.scroll_top.saturating_sub(overflow); + self.follow_top = self.follow_top.saturating_sub(overflow); + } + + /// Rows the output list can show; a sane page before the first frame + fn page_size(&self) -> usize { + self.view_height.max(1) + } + + fn total_rows(&self) -> usize { + self.output_lines.len() + usize::from(self.assembler.has_partial()) + } + + /// Highest top row at which the newest line is still visible + fn max_top(&self) -> usize { + self.total_rows().saturating_sub(self.page_size()) } - /// Index of the bottom rendered row while following: the partial line, - /// when shown, sits below the last complete line - fn follow_position(&self) -> usize { - let last = self.output_lines.len() - 1; - if self.assembler.has_partial() { - last + 1 + /// Top row of the current view, whether following or scrolled + fn current_top(&self) -> usize { + if self.auto_scroll { + self.follow_top.min(self.max_top()) } else { - last + self.scroll_top } } - pub fn scroll_up(&mut self) { - if self.output_lines.is_empty() { + /// Scroll so that `top` is the first visible row; reaching the newest + /// line resumes following new data + fn set_top(&mut self, top: usize) { + if top >= self.max_top() { + self.enable_auto_scroll(); return; } - // Disable auto-scroll when manually scrolling self.auto_scroll = false; + self.scroll_top = top; + self.needs_render = true; + } - let selected = self - .list_state - .selected() - .unwrap_or_else(|| self.follow_position()); - if selected > 0 { - self.list_state.select(Some(selected - 1)); - self.needs_render = true; - } + pub fn scroll_up_by(&mut self, rows: usize) { + self.set_top(self.current_top().saturating_sub(rows)); } - pub fn scroll_down(&mut self) { - if self.output_lines.is_empty() { - return; - } + pub fn scroll_down_by(&mut self, rows: usize) { + self.set_top(self.current_top().saturating_add(rows)); + } - // While following, the implicit position is the last line - let last = self.output_lines.len() - 1; - let selected = self.list_state.selected().unwrap_or(last); - if selected < last { - self.auto_scroll = false; - self.list_state.select(Some(selected + 1)); - self.needs_render = true; - } else { - // Scrolling past the last line resumes following new data - self.enable_auto_scroll(); - } + pub fn scroll_up(&mut self) { + self.scroll_up_by(1); } - pub fn scroll_to_bottom(&mut self) { - self.enable_auto_scroll(); + pub fn scroll_down(&mut self) { + self.scroll_down_by(1); } - pub fn enable_auto_scroll(&mut self) { - self.auto_scroll = true; - self.unseen_lines = 0; - self.list_state.select(None); // Clear selection when re-enabling auto-scroll - self.needs_render = true; + pub fn scroll_page_up(&mut self) { + self.scroll_up_by(self.page_size()); + } + + pub fn scroll_page_down(&mut self) { + self.scroll_down_by(self.page_size()); } pub fn scroll_to_home(&mut self) { - if !self.output_lines.is_empty() { - // Disable auto-scroll when manually scrolling to top - self.auto_scroll = false; - self.list_state.select(Some(0)); - self.needs_render = true; - } + self.set_top(0); } - pub fn scroll_page_up(&mut self, page_size: usize) { - if self.output_lines.is_empty() { - return; - } - self.auto_scroll = false; - let last = self.output_lines.len() - 1; - let current = self - .list_state - .selected() - .unwrap_or_else(|| self.follow_position()); - let new_selected = current.saturating_sub(page_size).min(last); - self.list_state.select(Some(new_selected)); - self.needs_render = true; + pub fn scroll_to_bottom(&mut self) { + self.enable_auto_scroll(); } - pub fn scroll_page_down(&mut self, page_size: usize) { - if self.output_lines.is_empty() { - return; - } - let last = self.output_lines.len() - 1; - let current = self.list_state.selected().unwrap_or(last); - let new_selected = (current + page_size).min(last); - if new_selected == last { - self.enable_auto_scroll(); - } else { - self.auto_scroll = false; - self.list_state.select(Some(new_selected)); - self.needs_render = true; - } + pub fn enable_auto_scroll(&mut self) { + self.auto_scroll = true; + self.unseen_lines = 0; + self.needs_render = true; } pub fn clear_output(&mut self) { self.output_lines.clear(); self.assembler.clear(); - self.list_state.select(None); - self.needs_render = true; + self.scroll_top = 0; + self.follow_top = 0; + self.enable_auto_scroll(); } pub fn push_history(&mut self, line: String) { @@ -409,50 +386,95 @@ mod tests { state } + /// A state as it looks after one frame was drawn while following + fn rendered_state(lines: usize, view_height: usize) -> AppState { + let mut state = state_with_lines(lines); + state.view_height = view_height; + state.follow_top = lines.saturating_sub(view_height); + state + } + #[test] - fn scroll_down_while_following_stays_at_bottom() { - let mut state = state_with_lines(50); + fn scroll_down_and_page_down_while_following_keep_following() { + let mut state = rendered_state(50, 18); state.scroll_down(); assert!(state.auto_scroll); - assert_eq!(state.list_state.selected(), None); + state.scroll_page_down(); + assert!(state.auto_scroll); } #[test] - fn page_down_while_following_stays_at_bottom() { - let mut state = state_with_lines(50); - state.scroll_page_down(10); + fn scroll_up_moves_the_view_one_row_and_down_resumes_following() { + let mut state = rendered_state(50, 18); + state.scroll_up(); + assert!(!state.auto_scroll); + assert_eq!(state.scroll_top, 31); + state.scroll_down(); assert!(state.auto_scroll); - assert_eq!(state.list_state.selected(), None); } #[test] - fn trimming_keeps_manual_scroll_window_anchored() { - let mut state = state_with_lines(MAX_OUTPUT_LINES); - state.scroll_up(); - state.list_state.select(Some(510)); - *state.list_state.offset_mut() = 500; + fn pages_move_by_the_visible_height() { + let mut state = rendered_state(50, 18); + state.scroll_page_up(); + assert_eq!(state.scroll_top, 14); + state.scroll_page_up(); + assert_eq!(state.scroll_top, 0); + state.scroll_page_down(); + assert_eq!(state.scroll_top, 18); + state.scroll_page_down(); + assert!(state.auto_scroll, "past the newest line resumes following"); + } - for _ in 0..10 { - state.add_notice("new".to_string()); - } - assert_eq!(state.list_state.selected(), Some(500)); - assert_eq!(state.list_state.offset(), 490); + #[test] + fn home_jumps_to_the_top_and_bottom_resumes_following() { + let mut state = rendered_state(50, 18); + state.scroll_to_home(); + assert!(!state.auto_scroll); + assert_eq!(state.scroll_top, 0); + state.scroll_to_bottom(); + assert!(state.auto_scroll); } #[test] - fn scroll_up_from_follow_lands_on_last_complete_line_above_partial() { - let mut state = state_with_lines(50); - state.add_data(b"partial"); + fn scrolling_is_a_noop_when_everything_fits() { + let mut state = rendered_state(5, 18); state.scroll_up(); - assert_eq!(state.list_state.selected(), Some(49)); + state.scroll_page_up(); + state.scroll_to_home(); + assert!(state.auto_scroll); } #[test] - fn page_up_from_follow_counts_the_partial_row() { - let mut state = state_with_lines(50); + fn partial_row_counts_toward_the_scroll_range() { + let mut state = rendered_state(50, 18); state.add_data(b"partial"); - state.scroll_page_up(10); - assert_eq!(state.list_state.selected(), Some(40)); + state.auto_scroll = false; + state.scroll_top = 31; + state.scroll_down(); + assert!(!state.auto_scroll, "row 32 still hides the partial line"); + state.scroll_down(); + assert!(state.auto_scroll); + } + + #[test] + fn trimming_keeps_manual_scroll_window_anchored() { + let mut state = rendered_state(MAX_OUTPUT_LINES, 18); + state.scroll_up(); + state.scroll_top = 500; + for _ in 0..10 { + state.add_notice("new".to_string()); + } + assert_eq!(state.scroll_top, 490); + } + + #[test] + fn clearing_output_resumes_following() { + let mut state = rendered_state(50, 18); + state.scroll_to_home(); + state.clear_output(); + assert!(state.auto_scroll); + assert!(state.output_lines.is_empty()); } fn state_with_input(text: &str) -> AppState { @@ -499,15 +521,4 @@ mod tests { state.delete_word_back(); assert_eq!(state.input_line, ""); } - - #[test] - fn scroll_up_then_down_moves_relative_to_bottom() { - let mut state = state_with_lines(50); - state.scroll_up(); - assert_eq!(state.list_state.selected(), Some(48)); - state.scroll_down(); - assert_eq!(state.list_state.selected(), Some(49)); - state.scroll_down(); - assert!(state.auto_scroll); - } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 03dbaec..22c0755 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -263,10 +263,10 @@ fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfi app_state.history_next(); } KeyCode::PageUp => { - app_state.scroll_page_up(10); + app_state.scroll_page_up(); } KeyCode::PageDown => { - app_state.scroll_page_down(10); + app_state.scroll_page_down(); } KeyCode::Home if key diff --git a/src/ui/rendering.rs b/src/ui/rendering.rs index 43d5a80..0b155eb 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -4,7 +4,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, }; use unicode_width::UnicodeWidthChar; @@ -33,7 +33,7 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { } let item_count = output_items.len(); - let mut output_list = List::new(output_items) + let output_list = List::new(output_items) .block( Block::default() .borders(Borders::ALL) @@ -41,7 +41,7 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { ) .style(Style::default().fg(Color::White)); - // Handle auto-scrolling vs manual scrolling + app_state.view_height = chunks[0].height.saturating_sub(2) as usize; if app_state.auto_scroll { // Keep the selection pinned to the bottom so the list follows new // data; no highlight, the selection is not user-visible state here @@ -49,11 +49,11 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { .auto_scroll_state .select(item_count.checked_sub(1)); f.render_stateful_widget(output_list, chunks[0], &mut app_state.auto_scroll_state); + // Remember where the view starts so manual scrolling continues from it + app_state.follow_top = app_state.auto_scroll_state.offset(); } else { - // Manual scrolling mode - use the user's scroll position - output_list = - output_list.highlight_style(Style::default().fg(Color::Black).bg(Color::White)); - f.render_stateful_widget(output_list, chunks[0], &mut app_state.list_state); + let mut state = ListState::default().with_offset(app_state.scroll_top); + f.render_stateful_widget(output_list, chunks[0], &mut state); } // Input line: keep the cursor visible by scrolling horizontally once the From 7efd3f2a20d1526871d88d3b102d2497bb055a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:16:57 +0200 Subject: [PATCH 26/34] feat: wrap long output lines with --wrap or Ctrl+T Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 2 + src/config.rs | 2 + src/main.rs | 5 ++ src/ui/app_state.rs | 7 +++ src/ui/mod.rs | 5 ++ src/ui/rendering.rs | 123 +++++++++++++++++++++++++++++++++++++++----- 6 files changed, 132 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 279e129..a134c32 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Options: --hex Display data as hex --raw Keep ANSI escape sequences instead of stripping them -e, --echo Show sent lines in the output, prefixed with "> " + -w, --wrap Wrap long lines instead of clipping them --log Log received data --tx-log Log transmitted data -t, --timestamps Add timestamps to display and logs (alias: --log-ts) @@ -84,6 +85,7 @@ Options: - **Ctrl+U / Ctrl+K / Ctrl+W**: Delete to start of line / to end of line / previous word - **Shift+↑↓ / Page Up/Down**: Scroll through output - **Shift+Home / Shift+End** (or Ctrl+Home / Ctrl+End): Jump to top / bottom of output (End resumes auto-scroll) +- **Ctrl+T**: Toggle wrapping of long lines - **Ctrl+L**: Clear output - **Ctrl+V, then a key**: Send that key as a raw control byte (e.g. Ctrl+V Ctrl+C sends 0x03) - **Esc**: Clear input line diff --git a/src/config.rs b/src/config.rs index 2fc0f35..20b56c1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -239,6 +239,8 @@ pub struct UiConfig { pub raw: bool, /// Show transmitted lines in the output pub echo: bool, + /// Start with long lines wrapped instead of clipped + pub wrap: bool, pub port_label: String, } diff --git a/src/main.rs b/src/main.rs index b85ea8c..18991bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,6 +87,10 @@ struct Args { #[arg(short = 'e', long)] echo: bool, + /// Wrap long lines instead of clipping them (toggle at runtime with Ctrl+T) + #[arg(short = 'w', long)] + wrap: bool, + /// Just list ports and exit #[arg(long)] list: bool, @@ -235,6 +239,7 @@ async fn main() -> Result<()> { show_ts: args.timestamps, raw: args.raw, echo: args.echo, + wrap: args.wrap, port_label: port_label(&port_name, baud, &framing), }; diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 4475f56..ac7a330 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -37,6 +37,7 @@ pub struct AppState { pub connected: bool, pub unseen_lines: usize, // Lines received while not following the output pub show_ts: bool, + pub wrap: bool, // Wrap long output lines instead of clipping them pub port_label: String, pub line_ending_label: &'static str, pub needs_render: bool, // Optimization: only render when needed @@ -68,6 +69,7 @@ impl AppState { connected: true, unseen_lines: 0, show_ts: timestamps, + wrap: false, port_label, line_ending_label, needs_render: true, @@ -208,6 +210,11 @@ impl AppState { self.needs_render = true; } + pub fn toggle_wrap(&mut self) { + self.wrap = !self.wrap; + self.needs_render = true; + } + pub fn clear_output(&mut self) { self.output_lines.clear(); self.assembler.clear(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 22c0755..3c8ffcb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -34,6 +34,7 @@ pub async fn run_ui( ui_config.port_label.clone(), ui_config.line_ending.describe(), ); + app_state.wrap = ui_config.wrap; let (mut input_rx, input_handle) = spawn_input_thread(ui_config.running.clone()); // Run the loop in a block so the input thread is stopped and joined on @@ -210,6 +211,9 @@ fn handle_key_event(key: KeyEvent, app_state: &mut AppState, ui_config: &UiConfi app_state.pending_literal = true; app_state.needs_render = true; } + KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app_state.toggle_wrap(); + } KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { app_state.input_home(); } @@ -352,6 +356,7 @@ mod tests { show_ts: false, raw: false, echo: false, + wrap: false, port_label: String::new(), }; (config, writer_rx) diff --git a/src/ui/rendering.rs b/src/ui/rendering.rs index 0b155eb..3e08c88 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -1,4 +1,5 @@ use super::app_state::{AppState, LineKind, OutputLine}; +use ratatui::text::Text; use ratatui::{ Frame, layout::{Constraint, Direction, Layout}, @@ -21,15 +22,23 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { .split(f.area()); // Serial monitor output - optimize by avoiding allocations where possible + // Lines are clipped at the border unless wrapping is on + let wrap_width = app_state + .wrap + .then(|| chunks[0].width.saturating_sub(2) as usize); let mut output_items: Vec = app_state .output_lines .iter() - .map(|line| ListItem::new(output_line(line))) + .map(|line| ListItem::new(output_line(line, wrap_width))) .collect(); // Show the line still being received below the completed output if let Some(partial) = app_state.assembler.partial_display() { - output_items.push(ListItem::new(partial)); + let lines: Vec = wrap_text(&partial, wrap_width) + .into_iter() + .map(|s| Line::raw(s.to_string())) + .collect(); + output_items.push(ListItem::new(Text::from(lines))); } let item_count = output_items.len(); @@ -83,20 +92,61 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { f.render_widget(status_line(app_state), chunks[2]); } -fn output_line(line: &OutputLine) -> Line<'_> { - match line.kind { - LineKind::Rx => Line::raw(line.text.as_str()), - LineKind::Tx => Line::from(vec![ - Span::styled(TX_PREFIX, Style::default().fg(Color::Cyan)), - Span::styled(line.text.as_str(), Style::default().fg(Color::Cyan)), - ]), - LineKind::Notice => Line::styled( - line.text.as_str(), +fn output_line(line: &OutputLine, wrap_width: Option) -> Text<'_> { + let (style, prefix) = match line.kind { + LineKind::Rx => (Style::default(), ""), + LineKind::Tx => (Style::default().fg(Color::Cyan), TX_PREFIX), + LineKind::Notice => ( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::ITALIC), + "", ), + }; + // The prefix takes room on the first row only + let first_width = wrap_width.map(|w| w.saturating_sub(prefix.len()).max(1)); + let mut rows = wrap_text(&line.text, first_width).into_iter(); + let mut lines: Vec = Vec::new(); + if let Some(first) = rows.next() { + lines.push(Line::from(vec![ + Span::styled(prefix, style), + Span::styled(first, style), + ])); } + if let Some(width) = wrap_width { + let rest: String = rows.collect(); + if !rest.is_empty() { + lines.extend( + wrap_text(&rest, Some(width)) + .into_iter() + .map(|s| Line::styled(s.to_string(), style)), + ); + } + } + Text::from(lines) +} + +/// Split text into rows no wider than `width` display columns. Without a +/// width the text is returned as a single row. Wide characters that would +/// straddle the edge start the next row. +fn wrap_text(text: &str, width: Option) -> Vec<&str> { + let Some(width) = width.filter(|w| *w > 0) else { + return vec![text]; + }; + let mut rows = Vec::new(); + let mut row_start = 0; + let mut row_width = 0; + for (idx, c) in text.char_indices() { + let w = c.width().unwrap_or(0); + if row_width + w > width && idx > row_start { + rows.push(&text[row_start..idx]); + row_start = idx; + row_width = 0; + } + row_width += w; + } + rows.push(&text[row_start..]); + rows } fn status_line(app_state: &AppState) -> Paragraph<'_> { @@ -126,6 +176,9 @@ fn status_line(app_state: &AppState) -> Paragraph<'_> { } spans.push(Span::raw(format!(" | {}", app_state.line_ending_label))); + if app_state.wrap { + spans.push(Span::raw(" | wrap")); + } if app_state.pending_literal { spans.push(Span::styled( @@ -134,10 +187,56 @@ fn status_line(app_state: &AppState) -> Paragraph<'_> { )); } else { spans.push(Span::styled( - " | Enter send, Up/Down history, Shift+Up/Down PgUp/PgDn scroll, Shift+End follow, Ctrl+L clear, Ctrl+V literal, Ctrl+C quit", + " | Enter send, Up/Down history, Shift+Up/Down PgUp/PgDn scroll, Shift+End follow, Ctrl+T wrap, Ctrl+L clear, Ctrl+V literal, Ctrl+C quit", Style::default().fg(Color::DarkGray), )); } Paragraph::new(Line::from(spans)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_text_splits_at_display_width() { + assert_eq!(wrap_text("abcdefgh", Some(3)), vec!["abc", "def", "gh"]); + assert_eq!(wrap_text("abc", Some(3)), vec!["abc"]); + assert_eq!(wrap_text("", Some(3)), vec![""]); + } + + #[test] + fn wrap_text_keeps_wide_characters_whole() { + // Each CJK character is two columns wide + assert_eq!(wrap_text("aζ—₯本", Some(3)), vec!["aζ—₯", "本"]); + } + + #[test] + fn wrap_text_without_width_returns_one_row() { + assert_eq!(wrap_text("anything at all", None), vec!["anything at all"]); + assert_eq!(wrap_text("x", Some(0)), vec!["x"]); + } + + #[test] + fn wrapped_short_lines_take_a_single_row() { + for kind in [LineKind::Rx, LineKind::Tx, LineKind::Notice] { + let line = OutputLine { + kind, + text: "short".to_string(), + }; + assert_eq!(output_line(&line, Some(40)).lines.len(), 1, "{kind:?}"); + } + } + + #[test] + fn tx_prefix_takes_room_on_the_first_row_only() { + let line = OutputLine { + kind: LineKind::Tx, + text: "abcdef".to_string(), + }; + let text = output_line(&line, Some(4)); + let rows: Vec = text.lines.iter().map(ToString::to_string).collect(); + assert_eq!(rows, vec!["> ab", "cdef"]); + } +} From 2d652b282eceb34457c39371e187fc4356f7ea46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:16:59 +0200 Subject: [PATCH 27/34] feat: add --mouse for wheel scrolling of the output Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 2 ++ src/main.rs | 15 ++++++++++++++- src/ui/mod.rs | 12 +++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a134c32..65ad790 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Options: --raw Keep ANSI escape sequences instead of stripping them -e, --echo Show sent lines in the output, prefixed with "> " -w, --wrap Wrap long lines instead of clipping them + --mouse Scroll output with the mouse wheel --log Log received data --tx-log Log transmitted data -t, --timestamps Add timestamps to display and logs (alias: --log-ts) @@ -85,6 +86,7 @@ Options: - **Ctrl+U / Ctrl+K / Ctrl+W**: Delete to start of line / to end of line / previous word - **Shift+↑↓ / Page Up/Down**: Scroll through output - **Shift+Home / Shift+End** (or Ctrl+Home / Ctrl+End): Jump to top / bottom of output (End resumes auto-scroll) +- **Mouse wheel**: Scrolls the output when started with `--mouse`. It is off by default because capturing the mouse makes most terminals require Shift+drag to select and copy text - **Ctrl+T**: Toggle wrapping of long lines - **Ctrl+L**: Clear output - **Ctrl+V, then a key**: Send that key as a raw control byte (e.g. Ctrl+V Ctrl+C sends 0x03) diff --git a/src/main.rs b/src/main.rs index 18991bd..0db0cd8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,9 @@ use config::{ }; use logging::LogSink; use port_discovery::{choose_port_interactive, get_available_ports, print_ports}; -use ratatui::crossterm::event::{DisableBracketedPaste, EnableBracketedPaste}; +use ratatui::crossterm::event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, +}; use ratatui::crossterm::execute; use serial_io::{SerialEvent, WriterMsg, spawn_supervisor, spawn_writer}; use std::path::PathBuf; @@ -91,6 +93,11 @@ struct Args { #[arg(short = 'w', long)] wrap: bool, + /// Scroll the output with the mouse wheel (the terminal then needs + /// Shift+drag to select text) + #[arg(long)] + mouse: bool, + /// Just list ports and exit #[arg(long)] list: bool, @@ -230,6 +237,9 @@ async fn main() -> Result<()> { // Best effort: terminals without bracketed paste still deliver pasted // text as key events let _ = execute!(std::io::stdout(), EnableBracketedPaste); + if args.mouse { + let _ = execute!(std::io::stdout(), EnableMouseCapture); + } let ui_config = UiConfig { running: running.clone(), @@ -246,6 +256,9 @@ async fn main() -> Result<()> { let ui_res = run_ui(&mut terminal, ui_rx, event_rx, ui_config).await; // Restore terminal before anything else can fail + if args.mouse { + let _ = execute!(std::io::stdout(), DisableMouseCapture); + } let _ = execute!(std::io::stdout(), DisableBracketedPaste); ratatui::try_restore().context("Failed to restore terminal")?; terminal.show_cursor()?; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3c8ffcb..7a47e7e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,7 +6,9 @@ pub use app_state::AppState; pub use rendering::draw_ui; use anyhow::Result; -use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind, +}; use ratatui::{Terminal, backend::Backend}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -16,6 +18,8 @@ use tokio::sync::mpsc; use crate::config::UiConfig; use crate::serial_io::{SerialEvent, WriterMsg}; +const MOUSE_SCROLL_ROWS: usize = 3; + #[derive(Debug)] pub enum UiMessage { Quit, @@ -119,6 +123,12 @@ fn handle_input_event(event: Event, app_state: &mut AppState, ui_config: &UiConf handle_key_event(k, app_state, ui_config); } Event::Paste(text) => handle_paste(&text, app_state, ui_config), + // Only delivered when --mouse enabled capture + Event::Mouse(m) => match m.kind { + MouseEventKind::ScrollUp => app_state.scroll_up_by(MOUSE_SCROLL_ROWS), + MouseEventKind::ScrollDown => app_state.scroll_down_by(MOUSE_SCROLL_ROWS), + _ => {} + }, Event::Resize(_, _) => app_state.needs_render = true, _ => {} } From fee873d9927d2acb81899b1d2e8bbae22ff96762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:18:36 +0200 Subject: [PATCH 28/34] feat: hide unknown-type ports unless --all-ports and sort ports naturally Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 3 +- src/main.rs | 7 +- src/port_discovery.rs | 185 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 179 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 65ad790..74e508c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Most terminal-based serial monitors are annoying to use - they have clunky inter - **Smart auto-scroll**: Follows new data, easy to switch to manual scrolling - **Auto-reconnect**: Keeps watching the port and resumes when the device comes back - **Clean TUI**: Split view with input at bottom, output on top, status bar with connection state -- **Auto-detect ports**: Just run `sermonizer` and it finds your device +- **Auto-detect ports**: Just run `sermonizer` and it finds your device; dead onboard UARTs stay out of the way unless you ask for `--all-ports` - **Sane defaults**: 115200 baud, 8 data bits, no parity, 1 stop bit - **Hex mode**: View binary data as hex bytes - **Clean text**: ANSI colour codes from firmware logs are stripped (keep them with `--raw`) @@ -75,6 +75,7 @@ Options: --tx-log Log transmitted data -t, --timestamps Add timestamps to display and logs (alias: --log-ts) --list List available ports + --all-ports Also list ports of unknown type (e.g. /dev/ttyS*), hidden by default ``` ## Controls diff --git a/src/main.rs b/src/main.rs index 0db0cd8..1e3fe75 100644 --- a/src/main.rs +++ b/src/main.rs @@ -101,6 +101,11 @@ struct Args { /// Just list ports and exit #[arg(long)] list: bool, + + /// Also list and offer ports of unknown type, such as onboard UARTs + /// (/dev/ttyS*), which are hidden by default + #[arg(long)] + all_ports: bool, } #[tokio::main] @@ -108,7 +113,7 @@ async fn main() -> Result<()> { let args = Args::parse(); // Enumerate ports up front - let ports = get_available_ports()?; + let ports = get_available_ports(args.all_ports)?; if args.list { print_ports(&ports); diff --git a/src/port_discovery.rs b/src/port_discovery.rs index 7780594..5a38164 100644 --- a/src/port_discovery.rs +++ b/src/port_discovery.rs @@ -2,19 +2,96 @@ use anyhow::{Context, Result, bail}; use serialport::{SerialPortInfo, SerialPortType}; use std::io::{self, Write}; -pub fn get_available_ports() -> Result> { - let mut ports = serialport::available_ports().context("Failed to list serial ports")?; +/// Ports worth offering to the user, plus how many were left out. +pub struct PortListing { + pub shown: Vec, + /// Ports of unknown type (typically dead onboard UARTs such as + /// /dev/ttyS*) that are only listed with --all-ports + pub hidden: usize, +} + +impl PortListing { + pub fn usb_count(&self) -> usize { + self.shown.iter().filter(|p| is_usb(p)).count() + } +} + +fn is_usb(port: &SerialPortInfo) -> bool { + matches!(&port.port_type, SerialPortType::UsbPort(_)) +} + +pub fn get_available_ports(include_all: bool) -> Result { + let ports = serialport::available_ports().context("Failed to list serial ports")?; + Ok(select_ports(ports, include_all)) +} +fn select_ports(ports: Vec, include_all: bool) -> PortListing { + let total = ports.len(); + let mut shown: Vec = ports + .into_iter() + .filter(|p| include_all || !matches!(p.port_type, SerialPortType::Unknown)) + .collect(); // USB ports first: they are the most likely embedded targets, but onboard - // UARTs, PCI and Bluetooth ports must stay selectable too - ports.sort_by_key(|p| !matches!(&p.port_type, SerialPortType::UsbPort(_))); + // UARTs, PCI and Bluetooth ports must stay selectable too. Within a group + // ttyUSB2 sorts before ttyUSB10. + shown.sort_by_cached_key(|p| (!is_usb(p), natural_key(&p.port_name))); + PortListing { + hidden: total - shown.len(), + shown, + } +} - Ok(ports) +/// Sort key that orders embedded digit runs numerically +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum NaturalPart { + Text(String), + // Comparing length first then digits orders numbers without parsing them + Number { len: usize, digits: String }, } -pub fn print_ports(ports: &[SerialPortInfo]) { +fn natural_key(name: &str) -> Vec { + let mut parts = Vec::new(); + let mut run = String::new(); + let mut run_is_digit = false; + for c in name.chars() { + if !run.is_empty() && c.is_ascii_digit() != run_is_digit { + parts.push(natural_part(std::mem::take(&mut run), run_is_digit)); + } + run_is_digit = c.is_ascii_digit(); + run.push(c); + } + if !run.is_empty() { + parts.push(natural_part(run, run_is_digit)); + } + parts +} + +fn natural_part(run: String, is_digit: bool) -> NaturalPart { + if is_digit { + let digits = run.trim_start_matches('0').to_string(); + NaturalPart::Number { + len: digits.len(), + digits, + } + } else { + NaturalPart::Text(run) + } +} + +pub fn print_ports(listing: &PortListing) { + let ports = &listing.shown; + if listing.usb_count() == 0 { + println!("No USB serial device found."); + } if ports.is_empty() { - println!("No serial ports found."); + if listing.hidden > 0 { + println!( + "{} port(s) of unknown type hidden; use --all-ports to list them.", + listing.hidden + ); + } else { + println!("No serial ports found."); + } return; } println!("Available serial ports:"); @@ -39,22 +116,30 @@ pub fn print_ports(ports: &[SerialPortInfo]) { } println!(); } + if listing.hidden > 0 { + println!( + "({} port(s) of unknown type hidden; use --all-ports to list them)", + listing.hidden + ); + } } -pub fn choose_port_interactive(ports: &[SerialPortInfo]) -> Result { +pub fn choose_port_interactive(listing: &PortListing) -> Result { + let ports = &listing.shown; // A sole USB port is almost certainly the target device; skip the prompt // even when onboard UARTs are also present (USB ports are sorted first) - let usb_count = ports - .iter() - .filter(|p| matches!(&p.port_type, SerialPortType::UsbPort(_))) - .count(); - if usb_count == 1 { + if listing.usb_count() == 1 { let name = ports[0].port_name.clone(); println!("Auto-selected sole USB port: {name}"); return Ok(name); } match ports.len() { + 0 if listing.hidden > 0 => bail!( + "No USB serial device found. Plug your device in and try again, \ + or use --all-ports to pick one of the {} port(s) of unknown type.", + listing.hidden + ), 0 => bail!("No serial ports detected. Plug your device in and try again."), 1 => { let name = ports[0].port_name.clone(); @@ -62,7 +147,7 @@ pub fn choose_port_interactive(ports: &[SerialPortInfo]) -> Result { Ok(name) } _ => { - print_ports(ports); + print_ports(listing); println!(); // Temporarily disable raw mode if it was on (it isn't yet, but be safe) @@ -120,6 +205,78 @@ fn parse_selection(input: &str, count: usize) -> Option { #[cfg(test)] mod tests { use super::*; + use serialport::UsbPortInfo; + + fn port(name: &str, port_type: SerialPortType) -> SerialPortInfo { + SerialPortInfo { + port_name: name.to_string(), + port_type, + } + } + + fn usb() -> SerialPortType { + SerialPortType::UsbPort(UsbPortInfo { + vid: 0x10c4, + pid: 0xea60, + serial_number: None, + manufacturer: None, + product: None, + }) + } + + fn names(listing: &PortListing) -> Vec<&str> { + listing.shown.iter().map(|p| p.port_name.as_str()).collect() + } + + #[test] + fn unknown_ports_are_hidden_unless_all_requested() { + let ports = vec![ + port("/dev/ttyS0", SerialPortType::Unknown), + port("/dev/ttyUSB0", usb()), + port("/dev/ttyS1", SerialPortType::Unknown), + ]; + let listing = select_ports(ports.clone(), false); + assert_eq!(names(&listing), vec!["/dev/ttyUSB0"]); + assert_eq!(listing.hidden, 2); + + let listing = select_ports(ports, true); + assert_eq!( + names(&listing), + vec!["/dev/ttyUSB0", "/dev/ttyS0", "/dev/ttyS1"] + ); + assert_eq!(listing.hidden, 0); + } + + #[test] + fn ports_sort_usb_first_then_naturally() { + let ports = vec![ + port("/dev/ttyS10", SerialPortType::Unknown), + port("/dev/ttyUSB10", usb()), + port("/dev/ttyS2", SerialPortType::Unknown), + port("/dev/ttyUSB2", usb()), + port("/dev/ttyACM0", usb()), + port("/dev/ttyS1", SerialPortType::PciPort), + ]; + let listing = select_ports(ports, true); + assert_eq!( + names(&listing), + vec![ + "/dev/ttyACM0", + "/dev/ttyUSB2", + "/dev/ttyUSB10", + "/dev/ttyS1", + "/dev/ttyS2", + "/dev/ttyS10" + ] + ); + } + + #[test] + fn natural_key_orders_com_ports_numerically() { + let mut names = vec!["COM10", "COM9", "COM1", "COM100"]; + names.sort_by_cached_key(|n| natural_key(n)); + assert_eq!(names, vec!["COM1", "COM9", "COM10", "COM100"]); + } #[test] fn empty_input_selects_first_port() { From 4ab7526e951509342e4decd0ffd1f070c866442d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:20:00 +0200 Subject: [PATCH 29/34] feat: show printable characters next to hex rows Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 2 +- src/ui/line_assembler.rs | 83 ++++++++++++++++++++++++++++++++-------- src/ui/mod.rs | 17 ++++---- 3 files changed, 76 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 74e508c..95dfd64 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Most terminal-based serial monitors are annoying to use - they have clunky inter - **Clean TUI**: Split view with input at bottom, output on top, status bar with connection state - **Auto-detect ports**: Just run `sermonizer` and it finds your device; dead onboard UARTs stay out of the way unless you ask for `--all-ports` - **Sane defaults**: 115200 baud, 8 data bits, no parity, 1 stop bit -- **Hex mode**: View binary data as hex bytes +- **Hex mode**: View binary data as hex bytes with a `hexdump -C` style ASCII column - **Clean text**: ANSI colour codes from firmware logs are stripped (keep them with `--raw`) - **Local echo**: `--echo` shows what you sent, for devices that do not echo - **Logging**: Save RX/TX data with timestamps diff --git a/src/ui/line_assembler.rs b/src/ui/line_assembler.rs index 8497240..d86fd5d 100644 --- a/src/ui/line_assembler.rs +++ b/src/ui/line_assembler.rs @@ -2,6 +2,8 @@ use chrono::Local; use std::fmt::Write as _; const HEX_BYTES_PER_LINE: usize = 16; +/// Width of a full row of hex bytes ("XX" plus separating spaces) +const HEX_COLS: usize = HEX_BYTES_PER_LINE * 3 - 1; const MAX_TEXT_LINE_BYTES: usize = 4096; const ESC: u8 = 0x1B; const BEL: u8 = 0x07; @@ -33,7 +35,9 @@ pub struct LineAssembler { strip_ansi: bool, escape: EscapeState, partial: Vec, - hex_row: String, + hex_ts: String, + hex_bytes: String, + hex_ascii: String, hex_col: usize, line_ts: Option, } @@ -46,7 +50,9 @@ impl LineAssembler { strip_ansi, escape: EscapeState::Text, partial: Vec::with_capacity(256), - hex_row: String::new(), + hex_ts: String::new(), + hex_bytes: String::new(), + hex_ascii: String::new(), hex_col: 0, line_ts: None, } @@ -139,25 +145,45 @@ impl LineAssembler { for &b in bytes { if self.hex_col == 0 { if self.timestamps { - self.hex_row.push_str(×tamp()); + self.hex_ts = timestamp(); } } else { - self.hex_row.push(' '); + self.hex_bytes.push(' '); } - let _ = write!(self.hex_row, "{b:02X}"); + let _ = write!(self.hex_bytes, "{b:02X}"); + self.hex_ascii.push(if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + }); self.hex_col += 1; if self.hex_col == HEX_BYTES_PER_LINE { - done.push(std::mem::take(&mut self.hex_row)); - self.hex_col = 0; + done.push(self.hex_row()); + self.clear_hex(); } } done } + /// Hex row in `hexdump -C` style: bytes, then the printable characters + fn hex_row(&self) -> String { + format!( + "{}{: bool { if self.hex { - !self.hex_row.is_empty() + self.hex_col > 0 } else { !self.partial.is_empty() } @@ -166,7 +192,7 @@ impl LineAssembler { /// The unfinished line, for display below the completed output. pub fn partial_display(&self) -> Option { if self.hex { - (!self.hex_row.is_empty()).then(|| self.hex_row.clone()) + (self.hex_col > 0).then(|| self.hex_row()) } else if self.partial.is_empty() { None } else { @@ -179,7 +205,7 @@ impl LineAssembler { pub fn finish(&mut self) -> Option { let line = if self.hex { - (!self.hex_row.is_empty()).then(|| std::mem::take(&mut self.hex_row)) + (self.hex_col > 0).then(|| self.hex_row()) } else if self.partial.is_empty() { None } else { @@ -194,8 +220,7 @@ impl LineAssembler { pub fn clear(&mut self) { self.escape = EscapeState::Text; self.partial.clear(); - self.hex_row.clear(); - self.hex_col = 0; + self.clear_hex(); self.line_ts = None; } } @@ -220,6 +245,24 @@ fn decode_complete_utf8_prefix(bytes: &[u8]) -> String { mod tests { use super::*; + fn hex_row(hex: &str, ascii: &str) -> String { + format!("{hex: Date: Thu, 10 Sep 2026 00:20:03 +0200 Subject: [PATCH 30/34] feat: hint at busy ports and missing permissions when opening fails Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/config.rs | 38 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 9 +++++---- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index 20b56c1..dcc7c61 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,6 +43,28 @@ impl PortSettings { } } +/// A hint for the two open failures users hit most: the port is held by +/// another program, or the user lacks permission on the device node. +pub fn open_hint(error: &serialport::Error) -> Option<&'static str> { + use serialport::ErrorKind; + let description = error.description.to_ascii_lowercase(); + match error.kind() { + ErrorKind::Io(std::io::ErrorKind::ResourceBusy) => Some(BUSY_HINT), + ErrorKind::Io(std::io::ErrorKind::PermissionDenied) => Some(PERMISSION_HINT), + ErrorKind::NoDevice => None, + _ if description.contains("busy") => Some(BUSY_HINT), + _ if description.contains("permission") || description.contains("access is denied") => { + Some(PERMISSION_HINT) + } + _ => None, + } +} + +const BUSY_HINT: &str = "Another program probably has the port open: a leftover screen/minicom/picocom \ +session, an IDE serial monitor, or ModemManager probing a new device."; +const PERMISSION_HINT: &str = "You lack permission on the device node. On Linux add your user to the \ +dialout (Debian/Ubuntu) or uucp (Arch) group and log in again."; + /// Explicit level for a control line #[derive(Copy, Clone, Debug, ValueEnum)] pub enum Toggle { @@ -248,6 +270,22 @@ pub struct UiConfig { mod tests { use super::*; + #[test] + fn open_hint_recognises_busy_and_permission_errors() { + use serialport::{Error, ErrorKind}; + let busy = Error::new(ErrorKind::Io(std::io::ErrorKind::ResourceBusy), "busy"); + assert_eq!(open_hint(&busy), Some(BUSY_HINT)); + let busy_text = Error::new(ErrorKind::Unknown, "Device or resource busy"); + assert_eq!(open_hint(&busy_text), Some(BUSY_HINT)); + let denied = Error::new( + ErrorKind::Io(std::io::ErrorKind::PermissionDenied), + "Permission denied", + ); + assert_eq!(open_hint(&denied), Some(PERMISSION_HINT)); + let missing = Error::new(ErrorKind::NoDevice, "No such file or directory"); + assert_eq!(open_hint(&missing), None); + } + #[test] fn port_label_uses_the_basename_with_baud_and_framing() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index 1e3fe75..0d989b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result}; use clap::Parser; use config::{ DataBitsArg, FlowControlArg, LineEnding, ParityArg, PortSettings, StopBitsArg, Toggle, - UiConfig, port_label, + UiConfig, open_hint, port_label, }; use logging::LogSink; use port_discovery::{choose_port_interactive, get_available_ports, print_ports}; @@ -175,9 +175,10 @@ async fn main() -> Result<()> { dtr: args.dtr.map(Toggle::as_bool), rts: args.rts.map(Toggle::as_bool), }; - let port = settings - .open() - .with_context(|| format!("Failed to open serial port '{port_name}'"))?; + let port = settings.open().map_err(|e| { + let hint = open_hint(&e).map(|h| format!("\n{h}")).unwrap_or_default(); + anyhow::Error::new(e).context(format!("Failed to open serial port '{port_name}'{hint}")) + })?; println!("Connected. Type to send; press Ctrl-C to exit.\n"); From 751fd5d70a9df4f7509a430f266c3b19a5cf43c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:20:05 +0200 Subject: [PATCH 31/34] fix: neutral exit message after a clean quit Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 0d989b3..301d777 100644 --- a/src/main.rs +++ b/src/main.rs @@ -279,6 +279,6 @@ async fn main() -> Result<()> { eprintln!("\nError: {e:?}"); } - println!("\nDisconnected. Bye!"); + println!("\nBye!"); Ok(()) } From 645717696b30e3e6162caf30ae4c1b5fbd1c5f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:22:11 +0200 Subject: [PATCH 32/34] docs: replace stale screenshot with a rendered text capture Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 23 ++++++++++++++- docs/screenshot.png | Bin 41253 -> 0 bytes src/ui/rendering.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) delete mode 100644 docs/screenshot.png diff --git a/README.md b/README.md index 95dfd64..8e719c7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,28 @@ A simple, clean serial monitor with a clean terminal UI for embedded development Most terminal-based serial monitors are annoying to use - they have clunky interfaces and no sane defaults. We wanted something that could be quickly spun up to interact with embedded devices during firmware development without any hassle. -![Screenshot](./docs/screenshot.png) +```text +β”ŒSerial Monitor────────────────────────────────────────────────────────────────────────────────┐ +β”‚[2026-09-10 09:41:02.118] I (312) boot: ESP-IDF v5.2 β”‚ +β”‚[2026-09-10 09:41:02.121] I (318) wifi: connecting to lab-iot β”‚ +β”‚[2026-09-10 09:41:03.877] I (2074) wifi: got ip 192.168.4.23 β”‚ +β”‚> [2026-09-10 09:41:07.402] AT+GMR β”‚ +β”‚[2026-09-10 09:41:07.410] AT version:2.4.0.0 β”‚ +β”‚[2026-09-10 09:41:07.411] OK β”‚ +β”‚[sermonizer] device disconnected: Broken pipe - reconnecting (Ctrl+C to quit) β”‚ +β”‚[sermonizer] device reconnected β”‚ +β”‚[2026-09-10 09:41:12.006] I (309) boot: ESP-IDF v5.2 β”‚ +β”‚[2026-09-10 09:41:12.009] I (315) main: sensor=23.4C hum=41% β”‚ +β”‚ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”ŒInput─────────────────────────────────────────────────────────────────────────────────────────┐ +β”‚AT+CWJAP="lab-iot"," β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ttyUSB0 115200 8N1 follow | LF | Enter send, Up/Down history, Shift+Up/Down PgUp/PgDn scroll, +``` + +*Received lines, a sent line (`>`, shown with `--echo`), sermonizer's own notices and the line still being received. Regenerate with `cargo test readme_screenshot -- --ignored --nocapture`.* ## Features diff --git a/docs/screenshot.png b/docs/screenshot.png deleted file mode 100644 index 1157e73e4f8a9104ce5458f1eb1fcfd36fc0f7f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41253 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 e-v46 AppState { + let mut state = AppState::new(false, false, true, "ttyUSB0 115200 8N1".to_string(), "LF"); + state.add_rx("[2026-09-10 09:41:02.118] I (312) boot: ESP-IDF v5.2".to_string()); + state.add_rx("[2026-09-10 09:41:02.121] I (318) wifi: connecting to lab-iot".to_string()); + state.add_rx("[2026-09-10 09:41:03.877] I (2074) wifi: got ip 192.168.4.23".to_string()); + state.add_tx("[2026-09-10 09:41:07.402] AT+GMR".to_string()); + state.add_rx("[2026-09-10 09:41:07.410] AT version:2.4.0.0".to_string()); + state.add_rx("[2026-09-10 09:41:07.411] OK".to_string()); + state.add_notice( + "[sermonizer] device disconnected: Broken pipe - reconnecting (Ctrl+C to quit)" + .to_string(), + ); + state.add_notice("[sermonizer] device reconnected".to_string()); + state.add_rx("[2026-09-10 09:41:12.006] I (309) boot: ESP-IDF v5.2".to_string()); + state.add_data(b"[2026-09-10 09:41:12.009] I (315) main: sensor=23.4C hum=41%"); + for c in "AT+CWJAP=\"lab-iot\",\"".chars() { + state.update_input(c); + } + state + } + + fn render(width: u16, height: u16) -> String { + use ratatui::{Terminal, backend::TestBackend}; + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("test terminal"); + let mut state = demo_state(); + terminal.draw(|f| draw_ui(f, &mut state)).expect("draw"); + let buffer = terminal.backend().buffer(); + (0..height) + .map(|y| { + (0..width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") + } + + #[test] + fn renders_without_panicking_on_tiny_terminals() { + for (w, h) in [(1, 1), (10, 3), (20, 5), (40, 6), (80, 24)] { + let screen = render(w, h); + assert_eq!(screen.lines().count(), usize::from(h), "{w}x{h}"); + } + } + + #[test] + fn output_shows_rx_tx_notices_and_the_partial_line() { + let screen = render(100, 18); + assert!( + screen.contains("> [2026-09-10 09:41:07.402] AT+GMR"), + "{screen}" + ); + assert!(screen.contains("device reconnected"), "{screen}"); + assert!(screen.contains("sensor=23.4C hum=41%"), "{screen}"); + assert!( + screen.contains(" ttyUSB0 115200 8N1 follow | LF"), + "{screen}" + ); + } + + /// Prints the README screenshot: cargo test readme_screenshot -- --ignored --nocapture + #[test] + #[ignore = "prints the README screenshot on demand"] + fn readme_screenshot() { + println!("{}", render(96, 18)); + } + #[test] fn wrapped_short_lines_take_a_single_row() { for kind in [LineKind::Rx, LineKind::Tx, LineKind::Notice] { From 1764718b3beb352c62f8a3557685e88f5bdc991d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:26:32 +0200 Subject: [PATCH 33/34] fix: exit quietly when the port list is piped into a pager that closes early Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- src/main.rs | 9 +++-- src/port_discovery.rs | 77 ++++++++++++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/src/main.rs b/src/main.rs index 301d777..0da2abd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,8 +116,13 @@ async fn main() -> Result<()> { let ports = get_available_ports(args.all_ports)?; if args.list { - print_ports(&ports); - return Ok(()); + // A pager that exits early closes our stdout; that is not an error + return match print_ports(&mut std::io::stdout().lock(), &ports) { + Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => { + Err(e).context("Failed to print port list") + } + _ => Ok(()), + }; } // Decide on port diff --git a/src/port_discovery.rs b/src/port_discovery.rs index 5a38164..9d87230 100644 --- a/src/port_discovery.rs +++ b/src/port_discovery.rs @@ -78,50 +78,53 @@ fn natural_part(run: String, is_digit: bool) -> NaturalPart { } } -pub fn print_ports(listing: &PortListing) { +/// Write the port list. Takes a writer instead of printing so a closed pipe +/// (`sermonizer --list | head`) surfaces as an error instead of a panic. +pub fn print_ports(out: &mut impl Write, listing: &PortListing) -> io::Result<()> { let ports = &listing.shown; if listing.usb_count() == 0 { - println!("No USB serial device found."); + writeln!(out, "No USB serial device found.")?; } if ports.is_empty() { if listing.hidden > 0 { - println!( + writeln!( + out, "{} port(s) of unknown type hidden; use --all-ports to list them.", listing.hidden - ); + )?; } else { - println!("No serial ports found."); + writeln!(out, "No serial ports found.")?; } - return; + return Ok(()); } - println!("Available serial ports:"); + writeln!(out, "Available serial ports:")?; for (i, p) in ports.iter().enumerate() { - print!(" [{}] {}", i + 1, p.port_name); + write!(out, " [{}] {}", i + 1, p.port_name)?; match &p.port_type { SerialPortType::UsbPort(info) => { - print!(" (USB"); - print!(" vid=0x{:04x}", info.vid); - print!(" pid=0x{:04x}", info.pid); + write!(out, " (USB vid=0x{:04x} pid=0x{:04x}", info.vid, info.pid)?; if let Some(m) = &info.manufacturer { - print!(" {m}"); + write!(out, " {m}")?; } if let Some(pn) = &info.product { - print!(" {pn}"); + write!(out, " {pn}")?; } - print!(")"); + write!(out, ")")?; } - SerialPortType::BluetoothPort => print!(" (Bluetooth)"), - SerialPortType::PciPort => print!(" (PCI)"), + SerialPortType::BluetoothPort => write!(out, " (Bluetooth)")?, + SerialPortType::PciPort => write!(out, " (PCI)")?, SerialPortType::Unknown => {} } - println!(); + writeln!(out)?; } if listing.hidden > 0 { - println!( + writeln!( + out, "({} port(s) of unknown type hidden; use --all-ports to list them)", listing.hidden - ); + )?; } + Ok(()) } pub fn choose_port_interactive(listing: &PortListing) -> Result { @@ -147,7 +150,7 @@ pub fn choose_port_interactive(listing: &PortListing) -> Result { Ok(name) } _ => { - print_ports(listing); + print_ports(&mut io::stdout().lock(), listing)?; println!(); // Temporarily disable raw mode if it was on (it isn't yet, but be safe) @@ -271,6 +274,40 @@ mod tests { ); } + #[test] + fn print_ports_lists_usb_details_and_hidden_count() { + let listing = select_ports( + vec![ + port("/dev/ttyUSB0", usb()), + port("/dev/ttyS0", SerialPortType::Unknown), + ], + false, + ); + let mut out = Vec::new(); + print_ports(&mut out, &listing).expect("write to a vec"); + assert_eq!( + String::from_utf8(out).expect("utf-8"), + "Available serial ports:\n [1] /dev/ttyUSB0 (USB vid=0x10c4 pid=0xea60)\n\ + (1 port(s) of unknown type hidden; use --all-ports to list them)\n" + ); + } + + #[test] + fn print_ports_reports_a_closed_pipe_instead_of_panicking() { + struct ClosedPipe; + impl Write for ClosedPipe { + fn write(&mut self, _: &[u8]) -> io::Result { + Err(io::Error::from(io::ErrorKind::BrokenPipe)) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let listing = select_ports(vec![port("/dev/ttyUSB0", usb())], false); + let err = print_ports(&mut ClosedPipe, &listing).expect_err("pipe is closed"); + assert_eq!(err.kind(), io::ErrorKind::BrokenPipe); + } + #[test] fn natural_key_orders_com_ports_numerically() { let mut names = vec!["COM10", "COM9", "COM1", "COM100"]; From 138dbad8819bfb843181ffc04022e1ce2d3b1dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Thu, 10 Sep 2026 00:27:16 +0200 Subject: [PATCH 34/34] chore: add justfile with screenshot recipes Claude-Session: https://claude.ai/code/session_01MKao7bog3JVAZ8PH7okEZT --- README.md | 2 +- justfile | 20 ++++++++++++++++++++ src/ui/rendering.rs | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 justfile diff --git a/README.md b/README.md index 8e719c7..7816fa1 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Most terminal-based serial monitors are annoying to use - they have clunky inter ttyUSB0 115200 8N1 follow | LF | Enter send, Up/Down history, Shift+Up/Down PgUp/PgDn scroll, ``` -*Received lines, a sent line (`>`, shown with `--echo`), sermonizer's own notices and the line still being received. Regenerate with `cargo test readme_screenshot -- --ignored --nocapture`.* +*Received lines, a sent line (`>`, shown with `--echo`), sermonizer's own notices and the line still being received. Regenerate with `just update-screenshot`.* ## Features diff --git a/justfile b/justfile new file mode 100644 index 0000000..7bfd34a --- /dev/null +++ b/justfile @@ -0,0 +1,20 @@ +# Project tasks. Needs only cargo; update-screenshot also uses awk. + +default: + @just --list + +# Render the README screenshot from the real UI code and print it +screenshot: + @cargo test --quiet readme_screenshot -- --ignored --nocapture | sed -n '/^β”Œ/,/^ ttyUSB0/p' + +# Replace the screenshot block in README.md with a fresh render +update-screenshot: + #!/usr/bin/env bash + set -euo pipefail + SHOT="$(just screenshot)" awk ' + /^```text$/ && !done { print; print ENVIRON["SHOT"]; skip = 1; next } + skip && /^```$/ { skip = 0; done = 1 } + !skip { print } + ' README.md > README.md.tmp + mv README.md.tmp README.md + git --no-pager diff --stat -- README.md diff --git a/src/ui/rendering.rs b/src/ui/rendering.rs index 59cb8dd..77e8dca 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -280,7 +280,7 @@ mod tests { ); } - /// Prints the README screenshot: cargo test readme_screenshot -- --ignored --nocapture + /// Prints the README screenshot; `just update-screenshot` writes it into README.md #[test] #[ignore = "prints the README screenshot on demand"] fn readme_screenshot() {