From 044eb2544368ebd65a73418a2a686db096e9b1e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Sun, 5 Jul 2026 20:44:04 +0200 Subject: [PATCH 1/8] fix: assemble display lines from raw bytes with UTF-8 and hex handling --- src/config.rs | 2 + src/main.rs | 4 +- src/ui/app_state.rs | 65 +++++---------- src/ui/line_assembler.rs | 173 +++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 5 +- src/ui/rendering.rs | 13 ++- 6 files changed, 214 insertions(+), 48 deletions(-) create mode 100644 src/ui/line_assembler.rs diff --git a/src/config.rs b/src/config.rs index 101e12c..daf51aa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -61,4 +61,6 @@ pub struct UiConfig { pub running: Arc, pub line_ending: LineEnding, pub writer: std::sync::mpsc::Sender, + pub hex: bool, + pub show_ts: bool, } diff --git a/src/main.rs b/src/main.rs index 3e0cb1f..aab0925 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,7 +44,7 @@ struct Args { #[arg(long)] tx_log: Option, - /// Prepend timestamps to logged chunks (and hex output) + /// Prepend timestamps to logged and displayed lines #[arg(long = "log-ts")] log_ts: bool, @@ -180,6 +180,8 @@ async fn main() -> Result<()> { running: running.clone(), line_ending, writer: writer_tx.clone(), + hex: args.hex, + show_ts: args.log_ts, }; let ui_res = run_ui(&mut terminal, ui_rx, event_rx, ui_config).await; diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index cef7da6..f8c930e 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -1,9 +1,13 @@ use ratatui::widgets::ListState; +use super::line_assembler::LineAssembler; + +const MAX_OUTPUT_LINES: usize = 1000; + pub struct AppState { pub input_line: String, pub output_lines: Vec, - pub partial_line: String, + pub assembler: LineAssembler, pub list_state: ListState, pub auto_scroll_state: ListState, pub should_quit: bool, @@ -12,11 +16,11 @@ pub struct AppState { } impl AppState { - pub fn new() -> Self { + pub fn new(hex: bool, timestamps: bool) -> Self { Self { input_line: String::new(), - output_lines: Vec::with_capacity(1000), // Pre-allocate capacity - partial_line: String::with_capacity(256), // Pre-allocate for partial lines + output_lines: Vec::with_capacity(MAX_OUTPUT_LINES), + assembler: LineAssembler::new(hex, timestamps), list_state: ListState::default(), auto_scroll_state: ListState::default(), should_quit: false, @@ -25,52 +29,27 @@ impl AppState { } } - pub fn add_output(&mut self, data: String) { - // Append to partial line buffer - self.partial_line.push_str(&data); - - // Check if we have complete lines (ending with \n or \r\n) - let mut has_new_lines = false; - while let Some(newline_pos) = self.partial_line.find('\n') { - // Extract complete line (without the newline) - let complete_line = self.partial_line[..newline_pos] - .trim_end_matches('\r') - .to_string(); - self.output_lines.push(complete_line); - has_new_lines = true; - - // Remove processed part from partial_line - self.partial_line.drain(..=newline_pos); - } - - // Only trigger expensive operations if we have new complete lines - if has_new_lines { - // Keep only the last 1000 lines to prevent memory issues - if self.output_lines.len() > 1000 { - self.output_lines.drain(..self.output_lines.len() - 1000); - } - - // Update auto-scroll state to point to the new bottom - if !self.output_lines.is_empty() { - self.auto_scroll_state - .select(Some(self.output_lines.len() - 1)); - } - - self.needs_render = true; - } + pub fn add_data(&mut self, bytes: &[u8]) { + self.output_lines.extend(self.assembler.push(bytes)); + self.trim_output(); + // The partial line is displayed too, so any data changes the view + self.needs_render = true; } - /// Push a complete status line (bypasses partial-line assembly). + /// Push a complete status line (bypasses line assembly). pub fn add_notice(&mut self, message: String) { self.output_lines.push(message); - if self.output_lines.len() > 1000 { - self.output_lines.drain(..self.output_lines.len() - 1000); - } - self.auto_scroll_state - .select(Some(self.output_lines.len() - 1)); + self.trim_output(); self.needs_render = true; } + fn trim_output(&mut self) { + if self.output_lines.len() > MAX_OUTPUT_LINES { + self.output_lines + .drain(..self.output_lines.len() - MAX_OUTPUT_LINES); + } + } + pub fn scroll_up(&mut self) { if self.output_lines.is_empty() { return; diff --git a/src/ui/line_assembler.rs b/src/ui/line_assembler.rs new file mode 100644 index 0000000..e32fcdf --- /dev/null +++ b/src/ui/line_assembler.rs @@ -0,0 +1,173 @@ +use chrono::Utc; +use std::fmt::Write as _; + +const HEX_BYTES_PER_LINE: usize = 16; + +/// Assembles raw serial bytes into display lines. Text mode buffers raw bytes +/// until a newline so multi-byte UTF-8 sequences split across reads survive; +/// hex mode emits fixed-width rows. +pub struct LineAssembler { + hex: bool, + timestamps: bool, + partial: Vec, + hex_row: String, + hex_col: usize, + line_ts: Option, +} + +impl LineAssembler { + pub fn new(hex: bool, timestamps: bool) -> Self { + Self { + hex, + timestamps, + partial: Vec::with_capacity(256), + hex_row: String::new(), + hex_col: 0, + line_ts: None, + } + } + + /// Feed received bytes; returns any lines completed by this chunk. + pub fn push(&mut self, bytes: &[u8]) -> Vec { + if self.hex { + self.push_hex(bytes) + } else { + self.push_text(bytes) + } + } + + fn push_text(&mut self, bytes: &[u8]) -> Vec { + let mut done = Vec::new(); + for &b in bytes { + if self.timestamps && self.line_ts.is_none() { + self.line_ts = Some(timestamp()); + } + if b == b'\n' { + 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)); + done.push(line); + self.partial.clear(); + } else { + self.partial.push(b); + } + } + done + } + + fn push_hex(&mut self, bytes: &[u8]) -> Vec { + let mut done = Vec::new(); + for &b in bytes { + if self.hex_col == 0 { + if self.timestamps { + self.hex_row.push_str(×tamp()); + } + } else { + self.hex_row.push(' '); + } + let _ = write!(self.hex_row, "{b:02X}"); + 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 + } + + /// 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()) + } else if self.partial.is_empty() { + None + } else { + let mut line = self.line_ts.clone().unwrap_or_default(); + let text = decode_complete_utf8_prefix(&self.partial); + line.push_str(text.trim_end_matches('\r')); + Some(line) + } + } + + pub fn clear(&mut self) { + self.partial.clear(); + self.hex_row.clear(); + self.hex_col = 0; + self.line_ts = None; + } +} + +fn timestamp() -> String { + format!("[{}] ", Utc::now().format("%Y-%m-%d %H:%M:%S%.3f")) +} + +/// Decode the longest UTF-8 prefix, hiding an incomplete trailing sequence +/// until the rest of it arrives. +fn decode_complete_utf8_prefix(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(s) => s.to_owned(), + Err(e) if e.error_len().is_none() => { + String::from_utf8_lossy(&bytes[..e.valid_up_to()]).into_owned() + } + Err(_) => String::from_utf8_lossy(bytes).into_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_line_split_across_chunks_completes_once() { + let mut asm = LineAssembler::new(false, false); + 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()]); + assert_eq!(asm.partial_display().as_deref(), Some("wor")); + } + + #[test] + fn crlf_is_trimmed_from_completed_and_partial_lines() { + let mut asm = LineAssembler::new(false, false); + 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 bytes = "grün\n".as_bytes(); + assert!(asm.push(&bytes[..3]).is_empty()); + // Incomplete trailing sequence is hidden, not shown as replacement char + assert_eq!(asm.partial_display().as_deref(), Some("gr")); + assert_eq!(asm.push(&bytes[3..]), vec!["grün".to_string()]); + assert_eq!(asm.partial_display(), None); + } + + #[test] + fn hex_rows_wrap_at_sixteen_bytes() { + let mut asm = LineAssembler::new(true, false); + let completed = asm.push(&[0xDE; 18]); + assert_eq!(completed, vec!["DE ".repeat(15) + "DE"]); + assert_eq!(asm.partial_display().as_deref(), Some("DE DE")); + } + + #[test] + fn timestamps_prefix_each_completed_line() { + let mut asm = LineAssembler::new(false, true); + let completed = asm.push(b"a\nb\n"); + assert_eq!(completed.len(), 2); + for line in &completed { + assert!(line.starts_with('['), "missing timestamp: {line}"); + assert_eq!(line.matches('[').count(), 1); + } + } + + #[test] + fn clear_resets_partial_state() { + let mut asm = LineAssembler::new(false, false); + asm.push(b"pending"); + asm.clear(); + assert_eq!(asm.partial_display(), None); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ae2f5a0..05e9659 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod app_state; +pub mod line_assembler; pub mod rendering; pub use app_state::AppState; @@ -25,7 +26,7 @@ pub async fn run_ui( mut serial_rx: mpsc::UnboundedReceiver, ui_config: UiConfig, ) -> Result<()> { - let mut app_state = AppState::new(); + let mut app_state = AppState::new(ui_config.hex, ui_config.show_ts); while ui_config.running.load(Ordering::SeqCst) && !app_state.should_quit { tokio::select! { @@ -78,7 +79,7 @@ pub async fn run_ui( fn handle_serial_event(event: SerialEvent, app_state: &mut AppState) { match event { SerialEvent::Data(bytes) => { - app_state.add_output(String::from_utf8_lossy(&bytes).into_owned()); + app_state.add_data(&bytes); } SerialEvent::Error(message) => { app_state.add_notice(format!("[sermonizer] {message}")); diff --git a/src/ui/rendering.rs b/src/ui/rendering.rs index 9af236e..43f2a66 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -16,18 +16,24 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { .split(f.area()); // Serial monitor output - optimize by avoiding allocations where possible - let output_items: Vec = app_state + let mut output_items: Vec = app_state .output_lines .iter() .map(|line| ListItem::new(line.as_str())) .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 title = if app_state.auto_scroll { "Serial Monitor (Auto-scroll ON - ↑↓/PgUp/PgDn to scroll, Ctrl+A to re-enable auto-scroll)" } else { "Serial Monitor (Auto-scroll OFF - ↑↓/PgUp/PgDn to scroll, Ctrl+A to re-enable auto-scroll)" }; + let item_count = output_items.len(); let output_list = List::new(output_items) .block(Block::default().borders(Borders::ALL).title(title)) .style(Style::default().fg(Color::White)) @@ -35,7 +41,10 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { // Handle auto-scrolling vs manual scrolling if app_state.auto_scroll { - // Use the persistent auto-scroll state that stays positioned at bottom + // Keep the selection pinned to the bottom so the list follows new data + app_state + .auto_scroll_state + .select(item_count.checked_sub(1)); f.render_stateful_widget(output_list, chunks[0], &mut app_state.auto_scroll_state); } else { // Manual scrolling mode - use the user's scroll position From 26241c5bd7b6aa4d2b5c016de4591d28e101ecd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Sun, 5 Jul 2026 20:44:47 +0200 Subject: [PATCH 2/8] fix: read terminal events on dedicated thread and redraw on resize --- src/ui/mod.rs | 77 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 29 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 05e9659..f8d22fb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,7 +8,8 @@ pub use rendering::draw_ui; use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::{Terminal, backend::Backend}; -use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use tokio::sync::mpsc; @@ -27,55 +28,73 @@ pub async fn run_ui( ui_config: UiConfig, ) -> Result<()> { let mut app_state = AppState::new(ui_config.hex, ui_config.show_ts); + 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(); + } + + if !ui_config.running.load(Ordering::SeqCst) || app_state.should_quit { + break; + } - while ui_config.running.load(Ordering::SeqCst) && !app_state.should_quit { tokio::select! { // UI messages (like quit from Ctrl-C) msg = ui_rx.recv() => { - if let Some(msg) = msg { - match msg { - UiMessage::Quit => { - app_state.quit(); - break; - } - } + match msg { + Some(UiMessage::Quit) | None => app_state.quit(), } } // Serial events event = serial_rx.recv() => { - if let Some(event) = event { - handle_serial_event(event, &mut app_state); + match event { + Some(event) => handle_serial_event(event, &mut app_state), + None => app_state.quit(), } } - // Keyboard input - async wrapper for crossterm events - key_result = async { - if event::poll(Duration::from_millis(0)).unwrap_or(false) { - event::read() - } else { - tokio::time::sleep(Duration::from_millis(1)).await; - Err(std::io::Error::new(std::io::ErrorKind::WouldBlock, "no input")) - } - } => { - if let Ok(Event::Key(k)) = key_result - && k.kind == KeyEventKind::Press { - handle_key_event(k, &mut app_state, &ui_config); + // 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(_) => {} + None => app_state.quit(), } } } - - // 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(); - } } ui_config.running.store(false, Ordering::SeqCst); + let _ = input_handle.join(); Ok(()) } +/// Reads terminal events on a dedicated thread so the UI loop can await them +/// instead of busy-polling. +fn spawn_input_thread( + running: Arc, +) -> (mpsc::UnboundedReceiver, std::thread::JoinHandle<()>) { + let (tx, rx) = mpsc::unbounded_channel(); + let handle = std::thread::spawn(move || { + while running.load(Ordering::SeqCst) { + if event::poll(Duration::from_millis(100)).unwrap_or(false) + && let Ok(ev) = event::read() + && tx.send(ev).is_err() + { + break; + } + } + }); + (rx, handle) +} + fn handle_serial_event(event: SerialEvent, app_state: &mut AppState) { match event { SerialEvent::Data(bytes) => { From 367c274f064b571ea7f5b2b723966f1c456ff588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Sun, 5 Jul 2026 20:45:17 +0200 Subject: [PATCH 3/8] fix: ignore unhandled modifier combos instead of inserting literal chars --- src/ui/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f8d22fb..60bfc20 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -132,7 +132,11 @@ fn handle_key_event( // Ctrl+A to re-enable auto-scroll app_state.enable_auto_scroll(); } - KeyCode::Char(c) => { + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { app_state.update_input(c); } KeyCode::Enter => { From 9f0a4448ea0f1542358d6c8cb0b4db6f4695c6fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Sun, 5 Jul 2026 20:46:24 +0200 Subject: [PATCH 4/8] feat: add input cursor movement with width-aware rendering --- Cargo.lock | 1 + Cargo.toml | 1 + src/ui/app_state.rs | 43 +++++++++++++++++++++++++++++++++++++++++-- src/ui/mod.rs | 9 +++++++++ src/ui/rendering.rs | 16 ++++++++++++++-- 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5e41c2..ce09d19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -900,6 +900,7 @@ dependencies = [ "ratatui", "serialport", "tokio", + "unicode-width 0.2.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index da893c7..776023a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ ratatui = "0.29" serialport = "4.3" tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "time", "macros"] } chrono = { version = "0.4", features = ["serde"] } +unicode-width = "0.2" diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index f8c930e..296d877 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -6,6 +6,7 @@ const MAX_OUTPUT_LINES: usize = 1000; pub struct AppState { pub input_line: String, + pub input_cursor: usize, // Cursor position as char index into input_line pub output_lines: Vec, pub assembler: LineAssembler, pub list_state: ListState, @@ -19,6 +20,7 @@ impl AppState { pub fn new(hex: bool, timestamps: bool) -> Self { Self { input_line: String::new(), + input_cursor: 0, output_lines: Vec::with_capacity(MAX_OUTPUT_LINES), assembler: LineAssembler::new(hex, timestamps), list_state: ListState::default(), @@ -131,17 +133,46 @@ impl AppState { } pub fn update_input(&mut self, c: char) { - self.input_line.push(c); + let byte_idx = self.input_byte_index(self.input_cursor); + self.input_line.insert(byte_idx, c); + self.input_cursor += 1; self.needs_render = true; } pub fn backspace_input(&mut self) { - if self.input_line.pop().is_some() { + if self.input_cursor == 0 { + return; + } + self.input_cursor -= 1; + let byte_idx = self.input_byte_index(self.input_cursor); + self.input_line.remove(byte_idx); + self.needs_render = true; + } + + pub fn delete_input(&mut self) { + let byte_idx = self.input_byte_index(self.input_cursor); + if byte_idx < self.input_line.len() { + self.input_line.remove(byte_idx); + self.needs_render = true; + } + } + + pub fn move_cursor_left(&mut self) { + if self.input_cursor > 0 { + self.input_cursor -= 1; + self.needs_render = true; + } + } + + pub fn move_cursor_right(&mut self) { + if self.input_cursor < self.input_line.chars().count() { + self.input_cursor += 1; self.needs_render = true; } } pub fn clear_input(&mut self) -> String { + self.input_cursor = 0; let input = std::mem::take(&mut self.input_line); if !input.is_empty() { self.needs_render = true; @@ -149,6 +180,14 @@ impl AppState { input } + fn input_byte_index(&self, char_idx: usize) -> usize { + self.input_line + .char_indices() + .nth(char_idx) + .map(|(i, _)| i) + .unwrap_or(self.input_line.len()) + } + pub fn quit(&mut self) { self.should_quit = true; self.needs_render = true; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 60bfc20..4bde40b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -145,6 +145,15 @@ fn handle_key_event( KeyCode::Backspace => { app_state.backspace_input(); } + KeyCode::Delete => { + app_state.delete_input(); + } + KeyCode::Left => { + app_state.move_cursor_left(); + } + KeyCode::Right => { + app_state.move_cursor_right(); + } KeyCode::Up => { app_state.scroll_up(); } diff --git a/src/ui/rendering.rs b/src/ui/rendering.rs index 43f2a66..1985312 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -5,6 +5,7 @@ use ratatui::{ style::{Color, Style}, widgets::{Block, Borders, List, ListItem, Paragraph}, }; +use unicode_width::UnicodeWidthChar; pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { let chunks = Layout::default() @@ -51,8 +52,19 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { f.render_stateful_widget(output_list, chunks[0], &mut app_state.list_state); } - // Input line + // Input line: keep the cursor visible by scrolling horizontally once the + // text is wider than the input area + let inner_width = chunks[1].width.saturating_sub(2) as usize; + let width_before_cursor: usize = app_state + .input_line + .chars() + .take(app_state.input_cursor) + .map(|c| c.width().unwrap_or(0)) + .sum(); + let h_scroll = width_before_cursor.saturating_sub(inner_width.saturating_sub(1)); + let input_paragraph = Paragraph::new(app_state.input_line.as_str()) + .scroll((0, h_scroll.try_into().unwrap_or(u16::MAX))) .block( Block::default() .borders(Borders::ALL) @@ -64,7 +76,7 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { // Set cursor position in input field f.set_cursor_position(( - chunks[1].x + app_state.input_line.len() as u16 + 1, + chunks[1].x + 1 + (width_before_cursor - h_scroll) as u16, chunks[1].y + 1, )); } From ee511e0d4c201d5951c42a1725ae4f401f114ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Sun, 5 Jul 2026 20:47:51 +0200 Subject: [PATCH 5/8] feat: add send history, output clearing, and literal control-byte sending --- src/ui/app_state.rs | 78 +++++++++++++++++++++++++++++++++++++++------ src/ui/mod.rs | 53 ++++++++++++++++++++++++++---- 2 files changed, 114 insertions(+), 17 deletions(-) diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 296d877..c5b6a86 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -7,6 +7,10 @@ const MAX_OUTPUT_LINES: usize = 1000; pub struct AppState { pub input_line: String, pub input_cursor: usize, // Cursor position as char index into input_line + pub history: Vec, + 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: Vec, pub assembler: LineAssembler, pub list_state: ListState, @@ -21,6 +25,10 @@ impl AppState { Self { input_line: String::new(), input_cursor: 0, + history: Vec::new(), + history_pos: None, + draft: String::new(), + pending_literal: false, output_lines: Vec::with_capacity(MAX_OUTPUT_LINES), assembler: LineAssembler::new(hex, timestamps), list_state: ListState::default(), @@ -73,23 +81,20 @@ impl AppState { if self.output_lines.is_empty() { return; } - // Disable auto-scroll when manually scrolling - self.auto_scroll = false; let selected = self.list_state.selected().unwrap_or(0); if selected < self.output_lines.len() - 1 { + 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_to_bottom(&mut self) { - if !self.output_lines.is_empty() { - // Disable auto-scroll when manually scrolling to bottom - self.auto_scroll = false; - self.list_state.select(Some(self.output_lines.len() - 1)); - self.needs_render = true; - } + self.enable_auto_scroll(); } pub fn enable_auto_scroll(&mut self) { @@ -125,10 +130,63 @@ impl AppState { if self.output_lines.is_empty() { return; } - self.auto_scroll = false; let current = self.list_state.selected().unwrap_or(0); let new_selected = (current + page_size).min(self.output_lines.len().saturating_sub(1)); - self.list_state.select(Some(new_selected)); + if new_selected == self.output_lines.len().saturating_sub(1) { + self.enable_auto_scroll(); + } else { + self.auto_scroll = false; + self.list_state.select(Some(new_selected)); + 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; + } + + pub fn push_history(&mut self, line: String) { + if !line.is_empty() && self.history.last() != Some(&line) { + self.history.push(line); + } + self.history_pos = None; + } + + pub fn history_prev(&mut self) { + if self.history.is_empty() { + return; + } + let pos = match self.history_pos { + None => { + self.draft = std::mem::take(&mut self.input_line); + self.history.len() - 1 + } + Some(p) => p.saturating_sub(1), + }; + self.history_pos = Some(pos); + self.set_input(self.history[pos].clone()); + } + + pub fn history_next(&mut self) { + let Some(pos) = self.history_pos else { + return; + }; + if pos + 1 < self.history.len() { + self.history_pos = Some(pos + 1); + self.set_input(self.history[pos + 1].clone()); + } else { + self.history_pos = None; + let draft = std::mem::take(&mut self.draft); + self.set_input(draft); + } + } + + fn set_input(&mut self, text: String) { + self.input_cursor = text.chars().count(); + self.input_line = text; self.needs_render = true; } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4bde40b..17ca8d3 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -119,18 +119,35 @@ fn handle_key_event( 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; + 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}")); + } + } + return; + } + match key.code { KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) && (c == 'c' || c == 'd') => { app_state.quit(); } - KeyCode::Esc => { - app_state.quit(); + KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app_state.clear_output(); } - KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // Ctrl+A to re-enable auto-scroll - app_state.enable_auto_scroll(); + KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app_state.pending_literal = true; + app_state.needs_render = true; + } + KeyCode::Esc => { + app_state.clear_input(); } KeyCode::Char(c) if !key @@ -154,12 +171,18 @@ fn handle_key_event( KeyCode::Right => { app_state.move_cursor_right(); } - KeyCode::Up => { + KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => { app_state.scroll_up(); } - KeyCode::Down => { + KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => { app_state.scroll_down(); } + KeyCode::Up => { + app_state.history_prev(); + } + KeyCode::Down => { + app_state.history_next(); + } KeyCode::PageUp => { app_state.scroll_page_up(10); } @@ -176,8 +199,24 @@ 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 { + match key.code { + KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => { + let c = c.to_ascii_uppercase(); + // Ctrl+A..Ctrl+Z map to 0x01..0x1A + c.is_ascii_uppercase().then(|| c as u8 - b'A' + 1) + } + KeyCode::Esc => Some(0x1B), + KeyCode::Enter => Some(b'\r'), + KeyCode::Tab => Some(b'\t'), + _ => None, + } +} + fn handle_enter_key(app_state: &mut AppState, ui_config: &UiConfig) { let input = app_state.clear_input(); + app_state.push_history(input.clone()); // Send input and line ending as a single write let mut bytes = input.into_bytes(); From d1512b83e69c78947e9535ed10efd2707233eec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Sun, 5 Jul 2026 20:48:27 +0200 Subject: [PATCH 6/8] fix: keep manual scroll anchored when output buffer is pruned --- src/ui/app_state.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index c5b6a86..3fd5bc7 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -1,4 +1,5 @@ use ratatui::widgets::ListState; +use std::collections::VecDeque; use super::line_assembler::LineAssembler; @@ -11,7 +12,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: Vec, + pub output_lines: VecDeque, pub assembler: LineAssembler, pub list_state: ListState, pub auto_scroll_state: ListState, @@ -29,7 +30,7 @@ impl AppState { history_pos: None, draft: String::new(), pending_literal: false, - output_lines: Vec::with_capacity(MAX_OUTPUT_LINES), + output_lines: VecDeque::with_capacity(MAX_OUTPUT_LINES), assembler: LineAssembler::new(hex, timestamps), list_state: ListState::default(), auto_scroll_state: ListState::default(), @@ -48,15 +49,22 @@ impl AppState { /// Push a complete status line (bypasses line assembly). pub fn add_notice(&mut self, message: String) { - self.output_lines.push(message); + self.output_lines.push_back(message); self.trim_output(); self.needs_render = true; } fn trim_output(&mut self) { - if self.output_lines.len() > MAX_OUTPUT_LINES { - self.output_lines - .drain(..self.output_lines.len() - MAX_OUTPUT_LINES); + let overflow = self.output_lines.len().saturating_sub(MAX_OUTPUT_LINES); + if overflow == 0 { + return; + } + self.output_lines.drain(..overflow); + // Keep the manual scroll position anchored to the same line 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))); } } From 429a3cddb3df33b95bdc3a71485e00a3afa64d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Sun, 5 Jul 2026 20:49:37 +0200 Subject: [PATCH 7/8] feat: add status bar with connection state, follow mode, and key hints --- src/config.rs | 1 + src/main.rs | 1 + src/ui/app_state.rs | 30 +++++++++++++++++-- src/ui/mod.rs | 9 +++++- src/ui/rendering.rs | 73 +++++++++++++++++++++++++++++++++++---------- 5 files changed, 95 insertions(+), 19 deletions(-) diff --git a/src/config.rs b/src/config.rs index daf51aa..d22246c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -63,4 +63,5 @@ pub struct UiConfig { pub writer: std::sync::mpsc::Sender, pub hex: bool, pub show_ts: bool, + pub port_label: String, } diff --git a/src/main.rs b/src/main.rs index aab0925..396b86e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -182,6 +182,7 @@ async fn main() -> Result<()> { writer: writer_tx.clone(), hex: args.hex, show_ts: args.log_ts, + port_label: format!("{port_name} @ {baud}"), }; let ui_res = run_ui(&mut terminal, ui_rx, event_rx, ui_config).await; diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 3fd5bc7..4d28294 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -18,11 +18,20 @@ pub struct AppState { pub auto_scroll_state: ListState, pub should_quit: bool, pub auto_scroll: bool, + pub connected: bool, + pub unseen_lines: usize, // Lines received while not following the output + pub port_label: String, + pub line_ending_label: &'static str, pub needs_render: bool, // Optimization: only render when needed } impl AppState { - pub fn new(hex: bool, timestamps: bool) -> Self { + pub fn new( + hex: bool, + timestamps: bool, + port_label: String, + line_ending_label: &'static str, + ) -> Self { Self { input_line: String::new(), input_cursor: 0, @@ -36,12 +45,20 @@ impl AppState { auto_scroll_state: ListState::default(), should_quit: false, auto_scroll: true, + connected: true, + unseen_lines: 0, + port_label, + line_ending_label, needs_render: true, } } pub fn add_data(&mut self, bytes: &[u8]) { - self.output_lines.extend(self.assembler.push(bytes)); + let completed = self.assembler.push(bytes); + if !self.auto_scroll { + self.unseen_lines += completed.len(); + } + self.output_lines.extend(completed); self.trim_output(); // The partial line is displayed too, so any data changes the view self.needs_render = true; @@ -49,11 +66,19 @@ impl AppState { /// Push a complete status line (bypasses line assembly). pub fn add_notice(&mut self, message: String) { + if !self.auto_scroll { + self.unseen_lines += 1; + } self.output_lines.push_back(message); self.trim_output(); self.needs_render = true; } + pub fn set_connected(&mut self, connected: bool) { + self.connected = connected; + self.needs_render = true; + } + fn trim_output(&mut self) { let overflow = self.output_lines.len().saturating_sub(MAX_OUTPUT_LINES); if overflow == 0 { @@ -107,6 +132,7 @@ impl AppState { 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; } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 17ca8d3..5399a10 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -27,7 +27,12 @@ pub async fn run_ui( mut serial_rx: mpsc::UnboundedReceiver, ui_config: UiConfig, ) -> Result<()> { - let mut app_state = AppState::new(ui_config.hex, ui_config.show_ts); + let mut app_state = AppState::new( + ui_config.hex, + ui_config.show_ts, + ui_config.port_label.clone(), + ui_config.line_ending.describe(), + ); let (mut input_rx, input_handle) = spawn_input_thread(ui_config.running.clone()); loop { @@ -104,11 +109,13 @@ fn handle_serial_event(event: SerialEvent, app_state: &mut AppState) { app_state.add_notice(format!("[sermonizer] {message}")); } SerialEvent::Disconnected(reason) => { + app_state.set_connected(false); app_state.add_notice(format!( "[sermonizer] device disconnected: {reason} — reconnecting (Ctrl+C to quit)" )); } SerialEvent::Reconnected => { + app_state.set_connected(true); app_state.add_notice("[sermonizer] device reconnected".to_string()); } } diff --git a/src/ui/rendering.rs b/src/ui/rendering.rs index 1985312..8f7cad6 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -3,6 +3,7 @@ use ratatui::{ Frame, layout::{Constraint, Direction, Layout}, style::{Color, Style}, + text::{Line, Span}, widgets::{Block, Borders, List, ListItem, Paragraph}, }; use unicode_width::UnicodeWidthChar; @@ -13,6 +14,7 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { .constraints([ Constraint::Min(1), // Output area (takes most space) Constraint::Length(3), // Input area (fixed height) + Constraint::Length(1), // Status bar ]) .split(f.area()); @@ -28,27 +30,27 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { output_items.push(ListItem::new(partial)); } - let title = if app_state.auto_scroll { - "Serial Monitor (Auto-scroll ON - ↑↓/PgUp/PgDn to scroll, Ctrl+A to re-enable auto-scroll)" - } else { - "Serial Monitor (Auto-scroll OFF - ↑↓/PgUp/PgDn to scroll, Ctrl+A to re-enable auto-scroll)" - }; - let item_count = output_items.len(); - let output_list = List::new(output_items) - .block(Block::default().borders(Borders::ALL).title(title)) - .style(Style::default().fg(Color::White)) - .highlight_style(Style::default().fg(Color::Black).bg(Color::White)); + let mut output_list = List::new(output_items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Serial Monitor"), + ) + .style(Style::default().fg(Color::White)); // Handle auto-scrolling vs manual scrolling if app_state.auto_scroll { - // Keep the selection pinned to the bottom so the list follows new data + // Keep the selection pinned to the bottom so the list follows new + // data; no highlight, the selection is not user-visible state here app_state .auto_scroll_state .select(item_count.checked_sub(1)); f.render_stateful_widget(output_list, chunks[0], &mut app_state.auto_scroll_state); } 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); } @@ -65,11 +67,7 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { let input_paragraph = Paragraph::new(app_state.input_line.as_str()) .scroll((0, h_scroll.try_into().unwrap_or(u16::MAX))) - .block( - Block::default() - .borders(Borders::ALL) - .title("Input (Press Enter to send, Ctrl+C or Esc to exit)"), - ) + .block(Block::default().borders(Borders::ALL).title("Input")) .style(Style::default().fg(Color::Yellow)); f.render_widget(input_paragraph, chunks[1]); @@ -79,4 +77,47 @@ pub fn draw_ui(f: &mut Frame, app_state: &mut AppState) { chunks[1].x + 1 + (width_before_cursor - h_scroll) as u16, chunks[1].y + 1, )); + + f.render_widget(status_line(app_state), chunks[2]); +} + +fn status_line(app_state: &AppState) -> Paragraph<'_> { + let mut spans: Vec = Vec::new(); + + if app_state.connected { + spans.push(Span::styled( + format!(" {} ", app_state.port_label), + Style::default().fg(Color::Black).bg(Color::Green), + )); + } else { + spans.push(Span::styled( + " 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")); + } else { + spans.push(Span::styled( + format!("scroll ({} new)", app_state.unseen_lines), + Style::default().fg(Color::Yellow), + )); + } + + if app_state.pending_literal { + spans.push(Span::styled( + " | Ctrl+V: next key is sent raw", + Style::default().fg(Color::Magenta), + )); + } else { + spans.push(Span::styled( + " | Enter send · ↑↓ history · Shift+↑↓/PgUp/PgDn scroll · End follow · Ctrl+L clear · Ctrl+V literal · Ctrl+C quit", + Style::default().fg(Color::DarkGray), + )); + } + + Paragraph::new(Line::from(spans)) } From d21711fec07883c2440fb9bacfae3838f9117e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Schulz-Andres?= Date: Sun, 5 Jul 2026 20:49:58 +0200 Subject: [PATCH 8/8] docs: update controls and features in README --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8be84a4..ae905a5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ Most terminal-based serial monitors are annoying to use - they have clunky inter ## Features - **Smart auto-scroll**: Follows new data, easy to switch to manual scrolling -- **Clean TUI**: Split view with input at bottom, output on top +- **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 - **Sane defaults**: 115200 baud, 8 data bits, no parity, 1 stop bit - **Hex mode**: View binary data as hex bytes @@ -67,9 +68,13 @@ Options: ## Controls - **Type and press Enter**: Send data to device -- **↑↓ / Page Up/Down**: Scroll through output -- **Ctrl+A**: Re-enable auto-scroll -- **Ctrl+C / Esc**: Exit +- **↑↓**: Browse send history +- **Shift+↑↓ / Page Up/Down**: Scroll through output +- **Home / End**: Jump to top / bottom (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 +- **Ctrl+C / Ctrl+D**: Exit ## Why?