diff --git a/Cargo.lock b/Cargo.lock index ce09d19..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", ] @@ -245,15 +244,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 +270,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 +330,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 +526,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 +708,7 @@ dependencies = [ "bitflags 2.9.4", "cassowary", "compact_str", - "crossterm 0.28.1", + "crossterm", "indoc", "instability", "itertools", @@ -814,23 +744,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" @@ -849,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" @@ -890,12 +787,11 @@ dependencies = [ [[package]] name = "sermonizer" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "chrono", "clap", - "crossterm 0.29.0", "ctrlc", "ratatui", "serialport", diff --git a/Cargo.toml b/Cargo.toml index de26a24..4be0641 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,19 +9,17 @@ 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"] [dependencies] anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } -crossterm = "0.29" 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 diff --git a/README.md b/README.md index a014578..7816fa1 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,39 @@ 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 `just update-screenshot`.* ## Features - **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 +- **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 - **Fast**: Built in Rust, handles high baud rates smoothly @@ -57,7 +80,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) @@ -65,18 +88,28 @@ 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 + -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 - --log-ts Add timestamps to logs and display + -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 - **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) +- **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) - **Esc**: Clear input line diff --git a/docs/screenshot.png b/docs/screenshot.png deleted file mode 100644 index 1157e73..0000000 Binary files a/docs/screenshot.png and /dev/null differ 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/config.rs b/src/config.rs index 454159c..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 { @@ -180,6 +202,7 @@ pub enum LineEnding { /// Send nothing extra (no line ending) None, /// Send '\n' (LF) + #[value(alias = "lf")] Nl, /// Send '\r' (CR) Cr, @@ -207,11 +230,76 @@ 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, 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, + /// Show transmitted lines in the output + pub echo: bool, + /// Start with long lines wrapped instead of clipped + pub wrap: bool, pub port_label: String, } + +#[cfg(test)] +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!( + 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/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/main.rs b/src/main.rs index 9b176b2..0da2abd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,12 +7,15 @@ 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, open_hint, port_label, }; -use crossterm::terminal; use logging::LogSink; use port_discovery::{choose_port_interactive, get_available_ports, print_ports}; -use ratatui::{Terminal, backend::CrosstermBackend}; +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; use std::sync::{ @@ -34,7 +37,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, @@ -70,17 +73,39 @@ 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)] hex: bool, + /// Keep ANSI escape sequences in the display instead of stripping them + #[arg(long)] + raw: bool, + + /// Show what you send in the output, prefixed with "> " + #[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, + + /// 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, + + /// 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] @@ -88,11 +113,16 @@ 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); - 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 @@ -107,11 +137,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() ); @@ -126,8 +159,14 @@ async fn main() -> Result<()> { if args.hex { println!("RX view: HEX"); } - if args.log_ts { - println!("Timestamps in logs: ON"); + if args.raw { + println!("ANSI escapes: kept"); + } + if args.echo { + println!("Local echo: ON"); + } + if args.timestamps { + println!("Timestamps: ON"); } // Open port @@ -141,9 +180,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"); @@ -151,12 +191,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 @@ -199,27 +239,39 @@ 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(); - 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")?; + // 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(), line_ending, writer: writer_tx.clone(), hex: args.hex, - show_ts: args.log_ts, - port_label: format!("{port_name} @ {baud}"), + show_ts: args.timestamps, + raw: args.raw, + echo: args.echo, + wrap: args.wrap, + port_label: port_label(&port_name, baud, &framing), }; let ui_res = run_ui(&mut terminal, ui_rx, event_rx, ui_config).await; - // Cleanup terminal - terminal::disable_raw_mode()?; - crossterm::execute!(terminal.backend_mut(), terminal::LeaveAlternateScreen)?; + // 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()?; // Ensure we stop and join the serial threads @@ -232,6 +284,6 @@ async fn main() -> Result<()> { eprintln!("\nError: {e:?}"); } - println!("\nDisconnected. Bye!"); + println!("\nBye!"); Ok(()) } diff --git a/src/port_discovery.rs b/src/port_discovery.rs index 6957797..9d87230 100644 --- a/src/port_discovery.rs +++ b/src/port_discovery.rs @@ -2,59 +2,147 @@ 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, + } +} + +/// 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 }, +} - Ok(ports) +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(ports: &[SerialPortInfo]) { +/// 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 { + writeln!(out, "No USB serial device found.")?; + } if ports.is_empty() { - println!("No serial ports found."); - return; + if listing.hidden > 0 { + writeln!( + out, + "{} port(s) of unknown type hidden; use --all-ports to list them.", + listing.hidden + )?; + } else { + writeln!(out, "No serial ports found.")?; + } + 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 { + writeln!( + out, + "({} port(s) of unknown type hidden; use --all-ports to list them)", + listing.hidden + )?; + } + Ok(()) } -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,17 +150,17 @@ pub fn choose_port_interactive(ports: &[SerialPortInfo]) -> Result { Ok(name) } _ => { - print_ports(ports); + print_ports(&mut io::stdout().lock(), listing)?; 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(); @@ -120,6 +208,112 @@ 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 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"]; + names.sort_by_cached_key(|n| natural_key(n)); + assert_eq!(names, vec!["COM1", "COM9", "COM10", "COM100"]); + } #[test] fn empty_input_selects_first_port() { diff --git a/src/serial_io.rs b/src/serial_io.rs index 013d6d5..89d04fb 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 @@ -38,48 +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); } - 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. - 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() { - let _ = events.send(SerialEvent::Reconnected); - port = Some(p); - } + // 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); + 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( @@ -132,6 +172,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 +199,67 @@ 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() { + 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:?}"), + } + } +} diff --git a/src/ui/app_state.rs b/src/ui/app_state.rs index 4d28294..ac7a330 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -1,10 +1,24 @@ 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; +/// 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,14 +26,18 @@ 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, + 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, 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 @@ -29,6 +47,7 @@ impl AppState { pub fn new( hex: bool, timestamps: bool, + strip_ansi: bool, port_label: String, line_ending_label: &'static str, ) -> Self { @@ -40,13 +59,17 @@ impl AppState { draft: String::new(), pending_literal: false, output_lines: VecDeque::with_capacity(MAX_OUTPUT_LINES), - assembler: LineAssembler::new(hex, timestamps), - list_state: ListState::default(), + assembler: LineAssembler::new(hex, timestamps, strip_ansi), auto_scroll_state: ListState::default(), + scroll_top: 0, + follow_top: 0, + view_height: 0, should_quit: false, auto_scroll: true, connected: true, unseen_lines: 0, + show_ts: timestamps, + wrap: false, port_label, line_ending_label, needs_render: true, @@ -58,7 +81,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; @@ -66,10 +93,29 @@ 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) { + let text = if self.show_ts { + format!("{}{text}", timestamp()) + } else { + text + }; + 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; } @@ -85,45 +131,73 @@ impl AppState { 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))); + // 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()) + } + + /// 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 { + 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(self.output_lines.len() - 1); - 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_by(&mut self, rows: usize) { + self.set_top(self.current_top().saturating_add(rows)); + } + + pub fn scroll_up(&mut self) { + self.scroll_up_by(1); } pub fn scroll_down(&mut self) { - if self.output_lines.is_empty() { - return; - } + self.scroll_down_by(1); + } - 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_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) { + self.set_top(0); } pub fn scroll_to_bottom(&mut self) { @@ -133,53 +207,20 @@ 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; } - 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; - } - } - - pub fn scroll_page_up(&mut self, page_size: usize) { - if self.output_lines.is_empty() { - return; - } - self.auto_scroll = false; - let current = self - .list_state - .selected() - .unwrap_or(self.output_lines.len().saturating_sub(1)); - let new_selected = current.saturating_sub(page_size); - self.list_state.select(Some(new_selected)); + pub fn toggle_wrap(&mut self) { + self.wrap = !self.wrap; self.needs_render = true; } - pub fn scroll_page_down(&mut self, page_size: usize) { - 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) { - 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; + self.scroll_top = 0; + self.follow_top = 0; + self.enable_auto_scroll(); } pub fn push_history(&mut self, line: String) { @@ -263,6 +304,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); @@ -289,3 +380,152 @@ 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, true, String::new(), "LF"); + for i in 0..n { + state.add_notice(format!("line {i}")); + } + 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_and_page_down_while_following_keep_following() { + let mut state = rendered_state(50, 18); + state.scroll_down(); + assert!(state.auto_scroll); + state.scroll_page_down(); + assert!(state.auto_scroll); + } + + #[test] + 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); + } + + #[test] + 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"); + } + + #[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 scrolling_is_a_noop_when_everything_fits() { + let mut state = rendered_state(5, 18); + state.scroll_up(); + state.scroll_page_up(); + state.scroll_to_home(); + assert!(state.auto_scroll); + } + + #[test] + fn partial_row_counts_toward_the_scroll_range() { + let mut state = rendered_state(50, 18); + state.add_data(b"partial"); + 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 { + 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, ""); + } +} diff --git a/src/ui/line_assembler.rs b/src/ui/line_assembler.rs index e757a2b..d86fd5d 100644 --- a/src/ui/line_assembler.rs +++ b/src/ui/line_assembler.rs @@ -1,28 +1,58 @@ -use chrono::Utc; +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; + +/// 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, + 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, } 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(), + hex_ts: String::new(), + hex_bytes: String::new(), + hex_ascii: String::new(), hex_col: 0, line_ts: None, } @@ -40,10 +70,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)); @@ -54,7 +91,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()); } @@ -65,30 +105,94 @@ 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::*; + if !self.strip_ansi { + return false; + } + 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 { 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_col > 0 + } else { + !self.partial.is_empty() + } + } + /// 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 { @@ -101,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 { @@ -114,15 +218,15 @@ 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; } } -fn timestamp() -> String { - format!("[{}] ", Utc::now().format("%Y-%m-%d %H:%M:%S%.3f")) +pub fn timestamp() -> String { + format!("[{}] ", Local::now().format("%Y-%m-%d %H:%M:%S%.3f")) } /// Decode the longest UTF-8 prefix, hiding an incomplete trailing sequence @@ -141,9 +245,27 @@ fn decode_complete_utf8_prefix(bytes: &[u8]) -> String { mod tests { use super::*; + fn hex_row(hex: &str, ascii: &str) -> String { + format!("{hex:( 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(), ); + app_state.wrap = ui_config.wrap; 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(Event::Key(k)) if 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(ev) => handle_input_event(ev, &mut app_state, &ui_config), + None => app_state.quit(), } - Some(Event::Resize(_, _)) => app_state.needs_render = true, - Some(_) => {} - 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); + } } + 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 @@ -100,6 +117,45 @@ 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::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, + _ => {} + } +} + +/// 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' | '\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; + } + } + 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) => { @@ -110,11 +166,11 @@ 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!( - "[sermonizer] device disconnected: {reason} — reconnecting (Ctrl+C to quit)" + "[sermonizer] device disconnected: {reason} - reconnecting (Ctrl+C to quit)" )); } SerialEvent::Reconnected => { @@ -124,21 +180,26 @@ 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; 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}")); - } + 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)" + .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_tx(format!("<0x{byte:02X}>")); } return; } @@ -152,10 +213,32 @@ fn handle_key_event( 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) => { + let _ = 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; } + 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(); + } + 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(); } @@ -167,7 +250,7 @@ fn handle_key_event( 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(); @@ -194,23 +277,37 @@ fn handle_key_event( 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 => { + 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(); + } _ => {} } } /// 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(); @@ -224,51 +321,177 @@ fn literal_byte(key: crossterm::event::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()); // 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; + return true; } if ui_config.writer.send(WriterMsg::Data(bytes)).is_err() { app_state.add_notice("[sermonizer] writer stopped, input dropped".to_string()); + return false; } + if ui_config.echo { + app_state.add_tx(input); + } + true } #[cfg(test)] 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(); + let config = UiConfig { + running: Arc::new(AtomicBool::new(true)), + line_ending: LineEnding::Nl, + writer, + hex: false, + show_ts: false, + raw: false, + echo: false, + wrap: 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 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(); + 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(); + let mut state = AppState::new(false, false, true, 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].text.contains("nothing sent"), + "{:?}", + state.output_lines + ); + } #[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); assert_eq!(state.assembler.partial_display(), None); - assert_eq!( - state.output_lines[0], - if hex { "62 65 66 6F 72 65" } else { "before" } - ); - assert!(state.output_lines[1].contains("device disconnected")); + if hex { + assert!(state.output_lines[0].text.starts_with("62 65 66 6F 72 65")); + assert!(state.output_lines[0].text.ends_with("|before|")); + } else { + assert_eq!(state.output_lines[0].text, "before"); + } + 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") - ); + let partial = state.assembler.partial_display().unwrap_or_default(); + assert!(partial.starts_with("61 66 74 65 72 0A"), "{partial}"); + assert!(partial.ends_with("|after.|"), "{partial}"); } 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 8f7cad6..77e8dca 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -1,13 +1,16 @@ -use super::app_state::AppState; +use super::app_state::{AppState, LineKind, OutputLine}; +use ratatui::text::Text; use ratatui::{ Frame, layout::{Constraint, Direction, Layout}, - style::{Color, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph}, + widgets::{Block, Borders, List, ListItem, ListState, 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) @@ -19,19 +22,27 @@ 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(line.as_str())) + .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(); - let mut output_list = List::new(output_items) + let output_list = List::new(output_items) .block( Block::default() .borders(Borders::ALL) @@ -39,7 +50,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 @@ -47,11 +58,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 @@ -81,9 +92,68 @@ 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, 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<'_> { 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), @@ -91,22 +161,25 @@ 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.wrap { + spans.push(Span::raw(" | wrap")); + } + if app_state.pending_literal { spans.push(Span::styled( " | Ctrl+V: next key is sent raw", @@ -114,10 +187,125 @@ 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, 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"]); + } + + fn demo_state() -> 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; `just update-screenshot` writes it into README.md + #[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] { + 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"]); + } +}