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/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? diff --git a/src/config.rs b/src/config.rs index 101e12c..d22246c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -61,4 +61,7 @@ pub struct UiConfig { pub running: Arc, pub line_ending: LineEnding, 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 3e0cb1f..396b86e 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,9 @@ async fn main() -> Result<()> { running: running.clone(), line_ending, 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 cef7da6..4d28294 100644 --- a/src/ui/app_state.rs +++ b/src/ui/app_state.rs @@ -1,76 +1,98 @@ use ratatui::widgets::ListState; +use std::collections::VecDeque; + +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 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: VecDeque, + pub assembler: LineAssembler, pub list_state: ListState, 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() -> Self { + pub fn new( + hex: bool, + timestamps: bool, + port_label: String, + line_ending_label: &'static str, + ) -> 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 + input_cursor: 0, + history: Vec::new(), + history_pos: None, + draft: String::new(), + pending_literal: false, + output_lines: VecDeque::with_capacity(MAX_OUTPUT_LINES), + assembler: LineAssembler::new(hex, timestamps), list_state: ListState::default(), 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_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]) { + 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; } - /// 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); + if !self.auto_scroll { + self.unseen_lines += 1; } - self.auto_scroll_state - .select(Some(self.output_lines.len() - 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 { + 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))); + } + } + pub fn scroll_up(&mut self) { if self.output_lines.is_empty() { return; @@ -92,27 +114,25 @@ 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) { self.auto_scroll = true; + self.unseen_lines = 0; self.list_state.select(None); // Clear selection when re-enabling auto-scroll self.needs_render = true; } @@ -144,25 +164,107 @@ 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; } 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; @@ -170,6 +272,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/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..5399a10 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; @@ -7,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; @@ -25,70 +27,95 @@ 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, + ui_config.port_label.clone(), + ui_config.line_ending.describe(), + ); + 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) => { - 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}")); } 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()); } } @@ -99,20 +126,41 @@ 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::Char(c) => { + KeyCode::Esc => { + app_state.clear_input(); + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { app_state.update_input(c); } KeyCode::Enter => { @@ -121,12 +169,27 @@ fn handle_key_event( KeyCode::Backspace => { app_state.backspace_input(); } - KeyCode::Up => { + KeyCode::Delete => { + app_state.delete_input(); + } + KeyCode::Left => { + app_state.move_cursor_left(); + } + KeyCode::Right => { + app_state.move_cursor_right(); + } + 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); } @@ -143,8 +206,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(); diff --git a/src/ui/rendering.rs b/src/ui/rendering.rs index 9af236e..8f7cad6 100644 --- a/src/ui/rendering.rs +++ b/src/ui/rendering.rs @@ -3,8 +3,10 @@ use ratatui::{ Frame, layout::{Constraint, Direction, Layout}, style::{Color, Style}, + text::{Line, Span}, 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() @@ -12,50 +14,110 @@ 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()); // 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(); - 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)" - }; + // 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 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 item_count = output_items.len(); + 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 { - // Use the persistent auto-scroll state that stays positioned at bottom + // 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); } - // 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()) - .block( - Block::default() - .borders(Borders::ALL) - .title("Input (Press Enter to send, Ctrl+C or Esc to exit)"), - ) + .scroll((0, h_scroll.try_into().unwrap_or(u16::MAX))) + .block(Block::default().borders(Borders::ALL).title("Input")) .style(Style::default().fg(Color::Yellow)); f.render_widget(input_paragraph, chunks[1]); // 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, )); + + 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)) }