Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?

Expand Down
3 changes: 3 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,7 @@ pub struct UiConfig {
pub running: Arc<AtomicBool>,
pub line_ending: LineEnding,
pub writer: std::sync::mpsc::Sender<WriterMsg>,
pub hex: bool,
pub show_ts: bool,
pub port_label: String,
}
5 changes: 4 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ struct Args {
#[arg(long)]
tx_log: Option<PathBuf>,

/// Prepend timestamps to logged chunks (and hex output)
/// Prepend timestamps to logged and displayed lines
#[arg(long = "log-ts")]
log_ts: bool,

Expand Down Expand Up @@ -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;
Expand Down
220 changes: 165 additions & 55 deletions src/ui/app_state.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub partial_line: String,
pub input_cursor: usize, // Cursor position as char index into input_line
pub history: Vec<String>,
pub history_pos: Option<usize>,
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<String>,
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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -144,32 +164,122 @@ 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;
}
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;
Expand Down
Loading
Loading