initial commit

This commit is contained in:
David F 2026-02-09 04:53:44 -05:00
commit ff2a0367d5
22 changed files with 4338 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2021
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

12
Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "rserial"
version = "0.1.0"
edition = "2024"
[dependencies]
argh = "0.1.14"
chrono = "0.4.43"
color-eyre = "0.6.5"
crossterm = "0.29.0"
ratatui = "0.30.0"
serialport = "4.8.1"

115
src/app/export.rs Normal file
View file

@ -0,0 +1,115 @@
use std::fmt::Write as FmtWrite;
use std::io;
use chrono::Local;
use super::App;
use super::state::MessageView;
use crate::serial::SerialMessage;
impl App {
/// Export the currently viewed buffer to a log file.
pub(super) fn export_current_view(&mut self) {
match self.write_export(false) {
Ok(filename) => {
self.view.set_status(format!("Exported to {filename}"));
}
Err(e) => {
self.view.set_status(format!("Export failed: {e}"));
}
}
}
/// Export all buffers to a single log file.
pub(super) fn export_all(&mut self) {
match self.write_export(true) {
Ok(filename) => {
self.view.set_status(format!("Exported to {filename}"));
}
Err(e) => {
self.view.set_status(format!("Export failed: {e}"));
}
}
}
fn write_export(&self, all: bool) -> io::Result<String> {
let mut output = String::new();
self.format_header(&mut output);
if all {
Self::format_messages(&mut output, "Received Messages", &self.messages.received);
Self::format_messages(&mut output, "Sent Messages", &self.messages.sent);
self.format_graphs(&mut output);
} else {
match self.view.current_view {
MessageView::Received => {
Self::format_messages(
&mut output,
"Received Messages",
&self.messages.received,
);
}
MessageView::Sent => {
Self::format_messages(&mut output, "Sent Messages", &self.messages.sent);
}
MessageView::Graphs => {
self.format_graphs(&mut output);
}
}
}
let filename = Local::now()
.format("rserial_log_%Y%m%d_%H%M%S.log")
.to_string();
std::fs::write(&filename, &output)?;
Ok(filename)
}
fn format_header(&self, out: &mut String) {
let now = Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(out, "=== rserial log export ===");
let _ = writeln!(out, "Port: {}", self.port);
let _ = writeln!(out, "Baud rate: {}", self.baud);
let _ = writeln!(out, "Export time: {now}");
let _ = writeln!(out, "=====================================");
let _ = writeln!(out);
}
fn format_messages(out: &mut String, title: &str, messages: &[SerialMessage]) {
let _ = writeln!(out, "--- {title} ({} entries) ---", messages.len());
for msg in messages {
let ts = msg.timestamp.format("%H:%M:%S");
let _ = writeln!(out, "[{ts}] {}", msg.message);
}
let _ = writeln!(out);
}
fn format_graphs(&self, out: &mut String) {
let _ = writeln!(out, "--- Graph Data ---");
let _ = writeln!(
out,
"Float values ({} entries):",
self.messages.graph_float.len()
);
let floats: Vec<String> = self
.messages
.graph_float
.iter()
.map(|v| v.to_string())
.collect();
let _ = writeln!(out, "{}", floats.join(", "));
let _ = writeln!(
out,
"Integer values ({} entries):",
self.messages.graph_int.len()
);
let ints: Vec<String> = self
.messages
.graph_int
.iter()
.map(|v| v.to_string())
.collect();
let _ = writeln!(out, "{}", ints.join(", "));
let _ = writeln!(out);
}
}

45
src/app/input.rs Normal file
View file

@ -0,0 +1,45 @@
use super::state::{Direction, InputBuffer};
impl InputBuffer {
pub(super) fn move_cursor(&mut self, dir: Direction) {
if dir == Direction::Left {
let cursor_moved_left = self.cursor.saturating_sub(1);
self.cursor = self.clamp_cursor(cursor_moved_left);
} else if dir == Direction::Right {
let cursor_moved_right = self.cursor.saturating_add(1);
self.cursor = self.clamp_cursor(cursor_moved_right);
}
}
pub(super) fn enter_char(&mut self, new_char: char) {
let index = self.byte_index();
self.text.insert(index, new_char);
self.move_cursor(Direction::Right);
}
fn byte_index(&self) -> usize {
self.text
.char_indices()
.map(|(i, _)| i)
.nth(self.cursor)
.unwrap_or(self.text.len())
}
pub(super) fn delete_char(&mut self) {
let is_not_cursor_leftmost = self.cursor != 0;
if is_not_cursor_leftmost {
let current_index = self.cursor;
let from_left_to_current_index = current_index - 1;
let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
let after_char_to_delete = self.text.chars().skip(current_index);
self.text = before_char_to_delete.chain(after_char_to_delete).collect();
self.move_cursor(Direction::Left);
}
}
fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
new_cursor_pos.clamp(0, self.text.chars().count())
}
}

168
src/app/mod.rs Normal file
View file

@ -0,0 +1,168 @@
mod export;
mod input;
mod render;
mod serial_handler;
mod state;
pub(crate) use state::InputMode;
pub use state::{App, AppExit};
use std::time::Duration;
use color_eyre::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::DefaultTerminal;
use crate::serial::SerialMessage;
use crate::widgets::CommandPalette;
use state::{Direction, MessageView};
const STATUS_MESSAGE_DURATION: Duration = Duration::from_secs(3);
impl App {
pub fn run(mut self, terminal: &mut DefaultTerminal) -> Result<AppExit> {
loop {
self.view.expire_status(STATUS_MESSAGE_DURATION);
terminal.draw(|frame| self.draw(frame))?;
self.sync.tick(&self.serial_connection);
if let Some(reason) = self.receive_serial() {
return Ok(AppExit::Disconnected(reason));
}
if event::poll(Duration::from_millis(5))?
&& let Event::Key(key) = event::read()?
{
match self.view.input_mode {
InputMode::Normal => match (key.code, key.modifiers) {
(KeyCode::Char('e'), _) => {
self.view.input_mode = InputMode::Editing;
}
(KeyCode::Char('q'), _) => {
return Ok(AppExit::Quit);
}
(KeyCode::Char('h'), _) => {
self.view.input_mode = InputMode::Help;
}
(KeyCode::Char('f'), _) => {
self.view.receiving = !self.view.receiving;
}
(KeyCode::Char('1'), _) => {
self.view.current_view = MessageView::Received;
}
(KeyCode::Char('2'), _) => {
self.view.current_view = MessageView::Sent;
}
(KeyCode::Char('3'), _) => {
self.view.current_view = MessageView::Graphs;
}
(KeyCode::Char('t'), _) => {
self.view.show_timestamps = !self.view.show_timestamps;
}
(KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => {
self.messages.graph_float.clear();
self.messages.graph_int.clear();
}
(KeyCode::Char('r'), _) => {
self.messages.received.clear();
self.view.list_state.select(None);
}
(KeyCode::Char('R'), _) => {
self.messages.sent.clear();
self.view.list_state.select(None);
}
(KeyCode::Char('c'), _) => {
if !self.sync.device_commands.is_empty() {
self.cmd_palette.filter.clear();
self.cmd_palette.list_state.select(Some(0));
self.view.input_mode = InputMode::CommandPalette;
}
}
(KeyCode::Char('s'), _) => {
self.sync.start_resync();
}
(KeyCode::Char('l'), _) => {
self.export_current_view();
}
(KeyCode::Char('L'), _) => {
self.export_all();
}
(KeyCode::Char('x'), _) => {
return Ok(AppExit::ManualDisconnect);
}
_ => {}
},
InputMode::Editing if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Enter => {
let text = self.input.take_input();
if !text.is_empty() {
self.messages.push_sent(SerialMessage::new(&text));
self.serial_connection.writeln(&text);
}
}
KeyCode::Char(to_insert) => self.input.enter_char(to_insert),
KeyCode::Backspace => self.input.delete_char(),
KeyCode::Left => self.input.move_cursor(Direction::Left),
KeyCode::Esc => self.view.input_mode = InputMode::Normal,
_ => {}
},
InputMode::Editing => {}
InputMode::Help => match key.code {
KeyCode::Esc | KeyCode::Char('h') => {
self.view.input_mode = InputMode::Normal;
}
_ => {}
},
InputMode::CommandPalette if key.kind == KeyEventKind::Press => {
match key.code {
KeyCode::Esc => {
self.view.input_mode = InputMode::Normal;
}
KeyCode::Up => {
let i = self
.cmd_palette
.list_state
.selected()
.unwrap_or(0)
.saturating_sub(1);
self.cmd_palette.list_state.select(Some(i));
}
KeyCode::Down => {
let i = self
.cmd_palette
.list_state
.selected()
.map(|i| i + 1)
.unwrap_or(0);
self.cmd_palette.list_state.select(Some(i));
}
KeyCode::Enter => {
if let Some(cmd) = CommandPalette::selected_command(
&self.sync.device_commands,
&self.cmd_palette.filter,
&self.cmd_palette.list_state,
) {
self.input.text = format!("{} ", cmd.name);
self.input.cursor = self.input.text.chars().count();
self.view.input_mode = InputMode::Editing;
}
}
KeyCode::Char(c) => {
self.cmd_palette.filter.push(c);
self.cmd_palette.list_state.select(Some(0));
}
KeyCode::Backspace => {
self.cmd_palette.filter.pop();
self.cmd_palette.list_state.select(Some(0));
}
_ => {}
}
}
InputMode::CommandPalette => {}
}
}
}
}
}

122
src/app/render.rs Normal file
View file

@ -0,0 +1,122 @@
use ratatui::{
Frame,
layout::{Constraint, Layout, Position},
style::{Color, Style},
text::{Line, Span},
widgets::Paragraph,
};
use super::App;
use super::state::{InputMode, MessageView};
use crate::constants::{DEFAULT_THEME, MIN_COLS, MIN_ROWS};
use crate::widgets::{
CommandPalette, GraphView, HelpBar, HelpPopup, InputField, MessageList, StatusBar,
};
impl App {
pub(super) fn draw(&self, frame: &mut Frame) {
let area = frame.area();
if area.width < MIN_COLS || area.height < MIN_ROWS {
let msg = format!("Terminal too small (need {}x{})", MIN_COLS, MIN_ROWS);
let paragraph = ratatui::widgets::Paragraph::new(msg).centered();
let centered = Layout::vertical([Constraint::Length(1)])
.flex(ratatui::layout::Flex::Center)
.split(area);
frame.render_widget(paragraph, centered[0]);
return;
}
let vertical = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(1),
]);
let [help_area, input_area, message_area, status_area] = vertical.areas(frame.area());
frame.render_widget(
HelpBar::new(self.view.input_mode, help_area.width),
help_area,
);
frame.render_widget(
InputField::new(&self.input.text, self.view.input_mode),
input_area,
);
if self.view.input_mode == InputMode::Editing {
frame.set_cursor_position(Position::new(
input_area.x + self.input.cursor as u16 + 1,
input_area.y + 1,
));
}
match self.view.current_view {
MessageView::Graphs => {
let float_data: Vec<f64> = self.messages.graph_float.iter().copied().collect();
let int_data: Vec<i64> = self.messages.graph_int.iter().copied().collect();
frame.render_widget(GraphView::new(&float_data, &int_data), message_area);
}
_ => {
let (messages, title, border_color) = match self.view.current_view {
MessageView::Received => (
self.messages.received.as_slice(),
"Received",
DEFAULT_THEME.received_border,
),
MessageView::Sent => (
self.messages.sent.as_slice(),
"Sent",
DEFAULT_THEME.sent_border,
),
MessageView::Graphs => unreachable!(),
};
frame.render_stateful_widget(
MessageList::new(messages, title, self.view.show_timestamps, border_color),
message_area,
&mut self.view.list_state.clone(),
);
}
}
// Status bar — show transient status message if active, else normal
if let Some((ref msg, _)) = self.view.status_message {
let spans = Line::from(vec![Span::styled(
format!(" {msg}"),
Style::default().fg(Color::Green).bold(),
)]);
frame.render_widget(
Paragraph::new(spans).style(Style::default().bg(DEFAULT_THEME.status_bar_bg)),
status_area,
);
} else {
self.render_normal_status(frame, status_area);
}
if self.view.input_mode == InputMode::Help {
HelpPopup::render_overlay(frame);
}
if self.view.input_mode == InputMode::CommandPalette {
CommandPalette::render_overlay(
frame,
&self.sync.device_commands,
&self.cmd_palette.filter,
&mut self.cmd_palette.list_state.clone(),
);
}
}
fn render_normal_status(&self, frame: &mut Frame, status_area: ratatui::layout::Rect) {
frame.render_widget(
StatusBar::new(
&self.port,
self.baud,
self.view.input_mode,
self.view.receiving,
self.sync.display(),
),
status_area,
);
}
}

153
src/app/serial_handler.rs Normal file
View file

@ -0,0 +1,153 @@
use std::time::Instant;
use super::App;
use super::state::{MessageStore, SyncManager};
use crate::constants::{
SYNC_ACK, SYNC_BEGIN, SYNC_COMMAND, SYNC_END, SYNC_INITIAL_DELAY, SYNC_MAX_RETRIES,
SYNC_TIMEOUT,
};
use crate::serial::{SerialEvent, SerialHandle, SerialMessage};
use crate::sync::{SyncState, parse_command_line};
use crate::widgets::SyncDisplay;
/// Lines that are part of the sync protocol and should never appear in user-visible messages.
const SYNC_PROTOCOL_MARKERS: &[&str] = &[SYNC_BEGIN, SYNC_END, SYNC_ACK];
impl SyncManager {
/// Drive the sync state machine forward. Called each loop iteration.
pub(super) fn tick(&mut self, serial: &SerialHandle) {
match &self.sync_state {
SyncState::Idle => {
if self.started_at.elapsed() >= SYNC_INITIAL_DELAY {
serial.writeln(SYNC_COMMAND);
self.sync_state = SyncState::AwaitingBegin {
sent_at: Instant::now(),
attempts: 1,
};
}
}
SyncState::AwaitingBegin { sent_at, attempts } => {
if sent_at.elapsed() >= SYNC_TIMEOUT {
if *attempts >= SYNC_MAX_RETRIES {
self.sync_state = SyncState::Failed;
} else {
let next_attempt = *attempts + 1;
serial.writeln(SYNC_COMMAND);
self.sync_state = SyncState::AwaitingBegin {
sent_at: Instant::now(),
attempts: next_attempt,
};
}
}
}
SyncState::Receiving { .. } | SyncState::Synced | SyncState::Failed => {}
}
}
/// Process a line through the sync protocol. Returns `true` if consumed.
pub(super) fn handle_line(&mut self, line: &str, serial: &SerialHandle) -> bool {
// Always swallow lines containing sync protocol markers (e.g. the
// device echoing back "unknown: #acknowledge-sync") regardless of
// current state.
if !matches!(self.sync_state, SyncState::AwaitingBegin { .. } | SyncState::Receiving { .. })
&& SYNC_PROTOCOL_MARKERS.iter().any(|m| line.contains(m))
{
return true;
}
match &self.sync_state {
SyncState::AwaitingBegin { .. } => {
if line == SYNC_BEGIN {
self.sync_state = SyncState::Receiving {
commands: Vec::new(),
};
return true;
}
}
SyncState::Receiving { .. } => {
if line == SYNC_END {
if let SyncState::Receiving { commands } =
std::mem::replace(&mut self.sync_state, SyncState::Synced)
{
self.device_commands = commands;
}
serial.writeln(SYNC_ACK);
return true;
}
if let SyncState::Receiving { commands } = &mut self.sync_state {
commands.push(parse_command_line(line));
}
return true;
}
_ => {}
}
false
}
/// Get the current sync display state for the status bar.
pub(super) fn display(&self) -> SyncDisplay {
match &self.sync_state {
SyncState::Idle | SyncState::AwaitingBegin { .. } => SyncDisplay::Pending,
SyncState::Receiving { .. } => SyncDisplay::Receiving,
SyncState::Synced => SyncDisplay::Synced(self.device_commands.len()),
SyncState::Failed => SyncDisplay::Failed,
}
}
/// Reset sync state for a manual re-sync.
pub(super) fn start_resync(&mut self) {
self.device_commands.clear();
self.started_at = Instant::now();
self.sync_state = SyncState::Idle;
}
}
impl MessageStore {
/// Try to parse a graph data line. Returns `true` if consumed.
pub(super) fn handle_graph_line(&mut self, line: &str) -> bool {
if let Some(val_str) = line.strip_prefix("#graphf ")
&& let Ok(val) = val_str.trim().parse::<f64>()
{
self.push_graph_float(val);
return true;
} else if let Some(val_str) = line.strip_prefix("#graphi ")
&& let Ok(val) = val_str.trim().parse::<i64>()
{
self.push_graph_int(val);
return true;
}
false
}
}
impl App {
/// Poll for serial events. Returns `Some(reason)` if the connection was lost.
pub(super) fn receive_serial(&mut self) -> Option<String> {
if !self.view.receiving {
return None;
}
while let Some(event) = self.serial_connection.try_recv() {
match event {
SerialEvent::LineReceived(msg) => {
if self.sync.handle_line(&msg.message, &self.serial_connection) {
// consumed by sync protocol
} else if self.messages.handle_graph_line(&msg.message) {
// consumed by graph parser
} else {
let idx = self.messages.push_received(msg);
self.view.auto_scroll(idx);
}
}
SerialEvent::Error(e) => {
let idx = self.messages.push_received(SerialMessage::new(e));
self.view.auto_scroll(idx);
}
SerialEvent::Disconnected(reason) => {
return Some(reason);
}
}
}
None
}
}

234
src/app/state.rs Normal file
View file

@ -0,0 +1,234 @@
use std::collections::VecDeque;
use std::time::Instant;
use ratatui::widgets::ListState;
use crate::constants::GRAPH_BUFFER_SIZE;
use crate::serial::{SerialHandle, SerialMessage};
use crate::sync::{DeviceCommand, SyncState};
/// How the app exited.
pub enum AppExit {
/// User chose to quit.
Quit,
/// Serial connection was lost.
Disconnected(String),
/// User manually disconnected (return to setup screen).
ManualDisconnect,
}
/// Mode enum
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InputMode {
#[default]
Normal,
Editing,
Help,
CommandPalette,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(super) enum MessageView {
#[default]
Received,
Sent,
Graphs,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Direction {
Left,
Right,
}
// ---------------------------------------------------------------------------
// Sub-structs
// ---------------------------------------------------------------------------
/// Text editing buffer and cursor.
#[derive(Debug)]
pub(super) struct InputBuffer {
pub(super) text: String,
pub(super) cursor: usize,
}
impl InputBuffer {
fn new() -> Self {
Self {
text: String::new(),
cursor: 0,
}
}
/// Drain the buffer and return its contents, resetting the cursor.
pub(super) fn take_input(&mut self) -> String {
let text = std::mem::take(&mut self.text);
self.cursor = 0;
text
}
}
/// Received/sent messages and graph data.
#[derive(Debug)]
pub(super) struct MessageStore {
pub(super) received: Vec<SerialMessage>,
pub(super) sent: Vec<SerialMessage>,
pub(super) graph_float: VecDeque<f64>,
pub(super) graph_int: VecDeque<i64>,
}
impl MessageStore {
fn new() -> Self {
Self {
received: Vec::new(),
sent: Vec::new(),
graph_float: VecDeque::new(),
graph_int: VecDeque::new(),
}
}
/// Push a received message. Returns the index of the new last element.
pub(super) fn push_received(&mut self, msg: SerialMessage) -> usize {
self.received.push(msg);
self.received.len().saturating_sub(1)
}
pub(super) fn push_sent(&mut self, msg: SerialMessage) {
self.sent.push(msg);
}
pub(super) fn push_graph_float(&mut self, val: f64) {
self.graph_float.push_back(val);
if self.graph_float.len() > GRAPH_BUFFER_SIZE {
self.graph_float.pop_front();
}
}
pub(super) fn push_graph_int(&mut self, val: i64) {
self.graph_int.push_back(val);
if self.graph_int.len() > GRAPH_BUFFER_SIZE {
self.graph_int.pop_front();
}
}
}
/// Display mode, toggle flags, list selection, and transient status.
#[derive(Debug)]
pub(super) struct ViewState {
pub(super) input_mode: InputMode,
pub(super) current_view: MessageView,
pub(super) show_timestamps: bool,
pub(super) receiving: bool,
pub(super) list_state: ListState,
pub(super) status_message: Option<(String, Instant)>,
}
impl ViewState {
fn new() -> Self {
Self {
input_mode: InputMode::Normal,
current_view: MessageView::default(),
show_timestamps: false,
receiving: true,
list_state: ListState::default(),
status_message: None,
}
}
pub(super) fn set_status(&mut self, msg: String) {
self.status_message = Some((msg, Instant::now()));
}
/// Expire the status message if its duration has elapsed.
pub(super) fn expire_status(&mut self, max_age: std::time::Duration) {
if let Some((_, created_at)) = &self.status_message
&& created_at.elapsed() >= max_age
{
self.status_message = None;
}
}
/// Auto-scroll the list to the given index.
pub(super) fn auto_scroll(&mut self, idx: usize) {
self.list_state.select(Some(idx));
}
}
/// Sync protocol state and discovered device commands.
#[derive(Debug)]
pub(super) struct SyncManager {
pub(super) device_commands: Vec<DeviceCommand>,
pub(super) sync_state: SyncState,
pub(super) started_at: Instant,
}
impl SyncManager {
fn new() -> Self {
Self {
device_commands: Vec::new(),
sync_state: SyncState::Idle,
started_at: Instant::now(),
}
}
}
/// Command palette filter and selection state.
#[derive(Debug)]
pub(super) struct CommandPaletteState {
pub(super) filter: String,
pub(super) list_state: ListState,
}
impl CommandPaletteState {
fn new() -> Self {
Self {
filter: String::new(),
list_state: ListState::default(),
}
}
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
/// State struct
#[derive(Debug)]
pub struct App {
pub(super) input: InputBuffer,
pub(super) messages: MessageStore,
pub(super) view: ViewState,
pub(super) sync: SyncManager,
pub(super) cmd_palette: CommandPaletteState,
pub(super) serial_connection: SerialHandle,
pub(super) port: String,
pub(super) baud: u32,
}
impl App {
pub fn new(port: &str, baud: u32) -> Self {
Self {
input: InputBuffer::new(),
messages: MessageStore::new(),
view: ViewState::new(),
sync: SyncManager::new(),
cmd_palette: CommandPaletteState::new(),
serial_connection: SerialHandle::new(port, baud),
port: port.to_string(),
baud,
}
}
pub fn new_mock(baud: u32) -> Self {
Self {
input: InputBuffer::new(),
messages: MessageStore::new(),
view: ViewState::new(),
sync: SyncManager::new(),
cmd_palette: CommandPaletteState::new(),
serial_connection: SerialHandle::mock(),
port: "mock".to_string(),
baud,
}
}
}

68
src/constants.rs Normal file
View file

@ -0,0 +1,68 @@
#![allow(
unused,
reason = "These constants may or may not be useful in every case."
)]
use std::time::Duration;
use ratatui::style::Color;
// Some useful constants, may be specific to my use case
pub const SEEED_VID: u16 = 0x303a; // Vendor ID (vid) for the ESP32-C3 board I am using
pub const SEEED_ESP32_C3: u16 = 0x1001; // Product ID (pid) for the ESP32-C3 board I have
pub const ESP32_MANUFACTURER: &str = "Espressif"; // Manufacturer ID for ESP32
pub const SERIAL_PORT: &str = "/dev/ttyACM1"; // The port I'm using on my desktop
pub const MIN_COLS: u16 = 80;
pub const MIN_ROWS: u16 = 24;
pub const COMMON_BAUD_RATES: &[u32] = &[9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
// --- Sync Protocol ---
pub const SYNC_COMMAND: &str = "/sync";
pub const SYNC_BEGIN: &str = "#sync-begin";
pub const SYNC_END: &str = "#sync-end";
pub const SYNC_ACK: &str = "#acknowledge-sync";
pub const SYNC_TIMEOUT: Duration = Duration::from_millis(2000);
pub const SYNC_MAX_RETRIES: u32 = 3;
pub const SYNC_INITIAL_DELAY: Duration = Duration::from_millis(500);
pub const SYNC_STATUS_COLOR: Color = Color::Cyan;
// --- Graph View ---
pub const GRAPH_BUFFER_SIZE: usize = 30;
// --- Theming ---
pub struct Theme {
pub input_active: Color,
pub received_border: Color,
pub sent_border: Color,
pub status_bar_bg: Color,
pub status_live: Color,
pub status_frozen: Color,
pub help_border: Color,
pub keybind: Color,
pub enum_text: Color,
pub help_label: Color,
pub graph_float_border: Color,
pub graph_int_border: Color,
}
pub const DEFAULT_THEME: Theme = Theme {
input_active: Color::Yellow,
received_border: Color::Green,
sent_border: Color::Red,
status_bar_bg: Color::DarkGray,
status_live: Color::Green,
status_frozen: Color::Red,
help_border: Color::White,
keybind: Color::White,
enum_text: Color::Yellow,
help_label: Color::Gray,
graph_float_border: Color::Cyan,
graph_int_border: Color::Magenta,
};

64
src/main.rs Normal file
View file

@ -0,0 +1,64 @@
use argh::FromArgs;
use crate::app::{App, AppExit};
use crate::setup::SetupScreen;
use color_eyre::Result;
mod app;
mod constants;
mod serial;
mod setup;
mod sync;
mod widgets;
/// RSerial CLI flags
#[derive(Debug, FromArgs)]
struct Cli {
/// baud rate of the serial connection. Defaults to 115200
#[argh(option, default = "115200")]
baud_rate: u32,
/// use a mock serial connection (no physical device needed)
#[argh(switch, short = 'm')]
mock: bool,
}
fn main() -> Result<()> {
color_eyre::install()?;
let mut cli: Cli = argh::from_env();
let mut terminal = ratatui::init();
let mut alert: Option<String> = None;
loop {
let app = if cli.mock {
cli.mock = false; // only auto-enter mock on first iteration
App::new_mock(cli.baud_rate)
} else {
let (port, baud) = match (&alert, serial::find_esp32_port()) {
// Auto-connect only when there's no alert (i.e. not returning from a disconnect)
(None, Some(port)) => (port, cli.baud_rate),
_ => {
let setup = SetupScreen::new(alert.take());
match setup.run(&mut terminal)? {
Some(result) => (result.port, result.baud),
None => break,
}
}
};
App::new(port.as_str(), baud)
};
match app.run(&mut terminal)? {
AppExit::Quit => break,
AppExit::Disconnected(reason) => {
alert = Some(reason);
}
AppExit::ManualDisconnect => {}
}
}
ratatui::restore();
Ok(())
}

284
src/serial.rs Normal file
View file

@ -0,0 +1,284 @@
use std::{sync::mpsc, thread, time::Duration};
use chrono::{DateTime, Local};
use serialport::{SerialPortType, available_ports};
use crate::constants::{ESP32_MANUFACTURER, SEEED_ESP32_C3, SEEED_VID};
#[derive(Debug)]
pub enum SerialCommand {
Write(String),
Shutdown,
}
#[derive(Debug)]
pub enum SerialEvent {
LineReceived(SerialMessage),
Error(String),
/// Fatal connection loss — the serial thread has exited.
Disconnected(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SerialMessage {
pub message: String,
pub timestamp: DateTime<Local>,
}
impl SerialMessage {
pub fn new(message: impl Into<String>) -> Self {
let message = message.into();
let timestamp = Local::now();
SerialMessage { message, timestamp }
}
}
#[derive(Debug)]
pub struct SerialHandle {
cmd_tx: mpsc::Sender<SerialCommand>,
event_rx: mpsc::Receiver<SerialEvent>,
thread_handle: Option<thread::JoinHandle<()>>,
}
impl SerialHandle {
pub fn new(port: &str, baud: u32) -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel();
let (event_tx, event_rx) = mpsc::channel();
let thread_handle = Some(spawn_serial_thread(port, baud, cmd_rx, event_tx));
SerialHandle {
cmd_tx,
event_rx,
thread_handle,
}
}
#[allow(dead_code)]
pub fn write(&self, data: impl Into<String>) {
let _ = self.cmd_tx.send(SerialCommand::Write(data.into()));
}
pub fn writeln(&self, data: impl Into<String>) {
let mut string = data.into();
if !string.ends_with('\n') {
string.push('\n');
}
let _ = self.cmd_tx.send(SerialCommand::Write(string));
}
pub fn try_recv(&self) -> Option<SerialEvent> {
self.event_rx.try_recv().ok()
}
pub fn shutdown(&self) {
let _ = self.cmd_tx.send(SerialCommand::Shutdown);
}
pub fn mock() -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel();
let (event_tx, event_rx) = mpsc::channel();
let thread_handle = Some(thread::spawn(move || {
let mut counter: u64 = 0;
let mut last_tick = std::time::Instant::now();
loop {
// Handle commands (non-blocking)
while let Ok(cmd) = cmd_rx.try_recv() {
match cmd {
SerialCommand::Write(data) => {
let line = data.trim_end().to_string();
event_tx
.send(SerialEvent::LineReceived(SerialMessage::new(format!(
"echo: {line}"
))))
.ok();
}
SerialCommand::Shutdown => return,
}
}
// Send periodic mock data every ~1 second
if last_tick.elapsed() >= Duration::from_secs(1) {
last_tick = std::time::Instant::now();
counter += 1;
event_tx
.send(SerialEvent::LineReceived(SerialMessage::new(format!(
"[mock] tick {counter}"
))))
.ok();
// Send sample graph data every few ticks
if counter.is_multiple_of(3) {
let val = (counter as f64 * 0.5).sin() * 100.0;
event_tx
.send(SerialEvent::LineReceived(SerialMessage::new(format!(
"#graphf {val:.2}"
))))
.ok();
}
if counter.is_multiple_of(5) {
let val = (counter % 200) as i64 - 100;
event_tx
.send(SerialEvent::LineReceived(SerialMessage::new(format!(
"#graphi {val}"
))))
.ok();
}
}
thread::sleep(Duration::from_millis(10));
}
}));
SerialHandle {
cmd_tx,
event_rx,
thread_handle,
}
}
}
impl Drop for SerialHandle {
fn drop(&mut self) {
self.shutdown();
if let Some(handle) = self.thread_handle.take() {
let _ = handle.join();
}
}
}
pub fn find_esp32_port() -> Option<String> {
let ports = available_ports().ok().unwrap_or_default();
for port in ports {
if let SerialPortType::UsbPort(info) = port.port_type
&& info.vid == SEEED_VID
&& info.pid == SEEED_ESP32_C3
{
return Some(port.port_name);
}
}
None
}
/// A detected ESP32-compatible serial port with display info.
#[derive(Debug, Clone)]
pub struct DetectedPort {
pub port_name: String,
pub description: String,
}
/// Scan for all USB serial ports matching any ESP32 constant (VID, PID, or manufacturer).
pub fn find_esp32_ports() -> Vec<DetectedPort> {
let ports = available_ports().ok().unwrap_or_default();
ports
.into_iter()
.filter_map(|port| {
if let SerialPortType::UsbPort(info) = &port.port_type {
let vid_match = info.vid == SEEED_VID;
let pid_match = info.pid == SEEED_ESP32_C3;
let mfr_match = info
.manufacturer
.as_deref()
.is_some_and(|m| m.contains(ESP32_MANUFACTURER));
if vid_match || pid_match || mfr_match {
let desc = format!(
"{} [VID:{:04x} PID:{:04x}{}]",
port.port_name,
info.vid,
info.pid,
info.manufacturer
.as_deref()
.map(|m| format!(" {m}"))
.unwrap_or_default(),
);
return Some(DetectedPort {
port_name: port.port_name,
description: desc,
});
}
}
None
})
.collect()
}
fn spawn_serial_thread(
port_name: &str,
baud_rate: u32,
cmd_rx: mpsc::Receiver<SerialCommand>,
event_tx: mpsc::Sender<SerialEvent>,
) -> thread::JoinHandle<()> {
let port_name = port_name.to_string();
thread::spawn(move || {
let mut port = match serialport::new(port_name, baud_rate)
.timeout(Duration::from_millis(10))
.open()
{
Ok(p) => p,
Err(e) => {
event_tx
.send(SerialEvent::Error(format!("Open error: {e}")))
.ok();
return;
}
};
let mut buf = [0u8; 256];
let mut line_buffer = String::new();
loop {
// Handle outgoing commands (non-blocking)
while let Ok(cmd) = cmd_rx.try_recv() {
match cmd {
SerialCommand::Write(data) => {
if let Err(e) = port.write_all(data.as_bytes()) {
event_tx
.send(SerialEvent::Error(format!("Write error: {e}")))
.ok();
}
}
SerialCommand::Shutdown => {
return;
}
}
}
// Read incoming data (blocking w/ timeout)
match port.read(&mut buf) {
Ok(n) if n > 0 => {
let chunk = String::from_utf8_lossy(&buf[..n]);
for c in chunk.chars() {
if c == '\n' {
if line_buffer.ends_with('\r') {
line_buffer.pop();
}
event_tx
.send(SerialEvent::LineReceived(SerialMessage::new(
line_buffer.clone(),
)))
.ok();
line_buffer.clear();
} else {
line_buffer.push(c);
}
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
// expected; ignore
}
Err(e) => {
event_tx
.send(SerialEvent::Disconnected(format!("Read error: {e}")))
.ok();
return;
}
_ => {} //Ok(n==0)
}
}
})
}

309
src/setup.rs Normal file
View file

@ -0,0 +1,309 @@
use std::time::{Duration, Instant};
use color_eyre::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::{
DefaultTerminal, Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Style, Stylize},
text::{Line, Span},
widgets::{Block, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::constants::{COMMON_BAUD_RATES, DEFAULT_THEME, MIN_COLS, MIN_ROWS};
use crate::serial::{DetectedPort, find_esp32_ports};
/// Result of the setup screen: selected port and baud rate.
pub struct SetupResult {
pub port: String,
pub baud: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Ports,
BaudRates,
}
pub struct SetupScreen {
ports: Vec<DetectedPort>,
port_state: ListState,
baud_state: ListState,
focus: Focus,
last_scan: Instant,
/// Optional alert shown as a popup (e.g. "Connection lost").
alert: Option<String>,
}
impl SetupScreen {
pub fn new(alert: Option<String>) -> Self {
let ports = find_esp32_ports();
let mut port_state = ListState::default();
if !ports.is_empty() {
port_state.select(Some(0));
}
let mut baud_state = ListState::default();
let default_baud_idx = COMMON_BAUD_RATES
.iter()
.position(|&b| b == 115200)
.unwrap_or(0);
baud_state.select(Some(default_baud_idx));
Self {
ports,
port_state,
baud_state,
focus: Focus::Ports,
last_scan: Instant::now(),
alert,
}
}
/// Run the setup screen. Returns `None` if the user quits.
pub fn run(mut self, terminal: &mut DefaultTerminal) -> Result<Option<SetupResult>> {
loop {
terminal.draw(|frame| self.draw(frame))?;
// Auto-scan every second
if self.last_scan.elapsed() >= Duration::from_secs(1) {
self.rescan();
}
if event::poll(Duration::from_millis(50))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
// Any keypress dismisses the alert
if self.alert.is_some() {
self.alert = None;
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
KeyCode::Tab | KeyCode::BackTab => {
self.focus = match self.focus {
Focus::Ports => Focus::BaudRates,
Focus::BaudRates => Focus::Ports,
};
}
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
KeyCode::Enter => {
if let Some(result) = self.try_confirm() {
return Ok(Some(result));
}
}
_ => {}
}
}
}
}
fn rescan(&mut self) {
self.last_scan = Instant::now();
let new_ports = find_esp32_ports();
// Preserve selection if the same port is still present
let selected_name = self
.port_state
.selected()
.and_then(|i| self.ports.get(i))
.map(|p| p.port_name.clone());
self.ports = new_ports;
if self.ports.is_empty() {
self.port_state.select(None);
} else if let Some(name) = selected_name {
let idx = self
.ports
.iter()
.position(|p| p.port_name == name)
.unwrap_or(0);
self.port_state.select(Some(idx));
} else {
self.port_state.select(Some(0));
}
}
fn move_selection(&mut self, delta: i32) {
let (state, len) = match self.focus {
Focus::Ports => (&mut self.port_state, self.ports.len()),
Focus::BaudRates => (&mut self.baud_state, COMMON_BAUD_RATES.len()),
};
if len == 0 {
return;
}
let current = state.selected().unwrap_or(0) as i32;
let next = (current + delta).rem_euclid(len as i32) as usize;
state.select(Some(next));
}
fn try_confirm(&self) -> Option<SetupResult> {
let port_idx = self.port_state.selected()?;
let baud_idx = self.baud_state.selected()?;
let port = self.ports.get(port_idx)?;
let &baud = COMMON_BAUD_RATES.get(baud_idx)?;
Some(SetupResult {
port: port.port_name.clone(),
baud,
})
}
fn draw(&mut self, frame: &mut Frame) {
let full = frame.area();
if full.width < MIN_COLS || full.height < MIN_ROWS {
let msg = format!("Terminal too small (need {}x{})", MIN_COLS, MIN_ROWS);
let paragraph = Paragraph::new(msg).centered();
let centered = Layout::vertical([Constraint::Length(1)])
.flex(Flex::Center)
.split(full);
frame.render_widget(paragraph, centered[0]);
return;
}
frame.render_widget(Clear, full);
let area = clamped_centered_rect(60, 22, full);
let outer = Block::bordered()
.title(" Serial Configuration ".bold())
.border_style(Style::default().fg(DEFAULT_THEME.help_border));
let inner = outer.inner(area);
frame.render_widget(outer, area);
let layout = Layout::vertical([
Constraint::Length(2), // header text
Constraint::Min(5), // port list
Constraint::Min(5), // baud list
Constraint::Length(2), // footer / keybinds
])
.split(inner);
// Header
let header = Paragraph::new(Line::from(vec![Span::styled(
" No ESP32 device auto-detected. Select a port and baud rate:",
Style::default().fg(DEFAULT_THEME.help_label),
)]));
frame.render_widget(header, layout[0]);
// Port list
let port_items: Vec<ListItem> = self
.ports
.iter()
.map(|p| ListItem::new(format!(" {}", p.description)))
.collect();
let port_block_style = if self.focus == Focus::Ports {
Style::default().fg(DEFAULT_THEME.input_active)
} else {
Style::default().fg(DEFAULT_THEME.help_label)
};
let port_list = if port_items.is_empty() {
List::new(vec![ListItem::new(
" (no ESP32 devices detected — scanning...)",
)])
.block(
Block::bordered()
.title(" Ports ")
.border_style(port_block_style),
)
} else {
List::new(port_items)
.block(
Block::bordered()
.title(" Ports ")
.border_style(port_block_style),
)
.highlight_style(Style::default().fg(DEFAULT_THEME.enum_text).bold())
.highlight_symbol("")
.highlight_spacing(HighlightSpacing::Always)
};
frame.render_stateful_widget(port_list, layout[1], &mut self.port_state);
// Baud rate list
let baud_items: Vec<ListItem> = COMMON_BAUD_RATES
.iter()
.map(|b| ListItem::new(format!(" {b}")))
.collect();
let baud_block_style = if self.focus == Focus::BaudRates {
Style::default().fg(DEFAULT_THEME.input_active)
} else {
Style::default().fg(DEFAULT_THEME.help_label)
};
let baud_list = List::new(baud_items)
.block(
Block::bordered()
.title(" Baud Rate ")
.border_style(baud_block_style),
)
.highlight_style(Style::default().fg(DEFAULT_THEME.enum_text).bold())
.highlight_symbol("")
.highlight_spacing(HighlightSpacing::Always);
frame.render_stateful_widget(baud_list, layout[2], &mut self.baud_state);
// Footer keybinds
let key = Style::default().fg(DEFAULT_THEME.keybind).bold();
let label = Style::default().fg(DEFAULT_THEME.help_label);
let footer = Paragraph::new(Line::from(vec![
Span::styled(" ↑↓", key),
Span::styled(" navigate ", label),
Span::styled("Tab", key),
Span::styled(" switch list ", label),
Span::styled("Enter", key),
Span::styled(" connect ", label),
Span::styled("q", key),
Span::styled(" quit", label),
]));
frame.render_widget(footer, layout[3]);
// Alert popup overlay
if let Some(ref msg) = self.alert {
self.draw_alert(frame, msg);
}
}
fn draw_alert(&self, frame: &mut Frame, message: &str) {
let alert_area = clamped_centered_rect(50, 8, frame.area());
frame.render_widget(Clear, alert_area);
let block = Block::bordered()
.title(" Connection Lost ".bold())
.border_style(Style::default().fg(DEFAULT_THEME.status_frozen));
let text = vec![
Line::from(""),
Line::from(Span::styled(
message,
Style::default().fg(DEFAULT_THEME.status_frozen),
)),
Line::from(""),
Line::from(Span::styled(
"Press any key to dismiss",
Style::default().fg(DEFAULT_THEME.help_label),
)),
];
let paragraph = Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: false })
.centered();
frame.render_widget(paragraph, alert_area);
}
}
fn clamped_centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
let vertical = Layout::vertical([Constraint::Length(h)])
.flex(Flex::Center)
.split(area);
Layout::horizontal([Constraint::Length(w)])
.flex(Flex::Center)
.split(vertical[0])[0]
}

34
src/sync.rs Normal file
View file

@ -0,0 +1,34 @@
use std::time::Instant;
#[derive(Debug, Clone)]
pub struct DeviceCommand {
pub name: String,
pub usage: Option<String>,
}
#[derive(Debug)]
pub enum SyncState {
/// Waiting for initial delay before first /sync
Idle,
/// /sync sent, waiting for #sync-begin
AwaitingBegin { sent_at: Instant, attempts: u32 },
/// Accumulating command lines between #sync-begin and #sync-end
Receiving { commands: Vec<DeviceCommand> },
/// Sync completed successfully
Synced,
/// Max retries exhausted
Failed,
}
pub fn parse_command_line(line: &str) -> DeviceCommand {
match line.split_once(' ') {
Some((name, usage)) => DeviceCommand {
name: name.to_string(),
usage: Some(usage.to_string()),
},
None => DeviceCommand {
name: line.to_string(),
usage: None,
},
}
}

View file

@ -0,0 +1,145 @@
use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Style, Stylize},
text::{Line, Span},
widgets::{Block, Clear, List, ListItem, ListState, Paragraph},
};
use crate::constants::DEFAULT_THEME;
use crate::sync::DeviceCommand;
pub struct CommandPalette;
impl CommandPalette {
pub fn render_overlay(
frame: &mut Frame,
commands: &[DeviceCommand],
filter: &str,
list_state: &mut ListState,
) {
let filtered: Vec<&DeviceCommand> = commands
.iter()
.filter(|cmd| {
if filter.is_empty() {
true
} else {
cmd.name.to_lowercase().contains(&filter.to_lowercase())
}
})
.collect();
let popup_block = Block::bordered()
.title("Commands".bold())
.border_style(Style::default().fg(DEFAULT_THEME.help_border));
// Filter input line
let filter_line = if filter.is_empty() {
Line::from(Span::styled(
" type to filter...",
Style::default().fg(DEFAULT_THEME.help_label).italic(),
))
} else {
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(filter, Style::default().fg(DEFAULT_THEME.keybind)),
])
};
// Build list items
let items: Vec<ListItem> = filtered
.iter()
.map(|cmd| {
let mut spans = vec![Span::styled(
&cmd.name,
Style::default().fg(DEFAULT_THEME.enum_text).bold(),
)];
if let Some(usage) = &cmd.usage {
spans.push(Span::raw(" "));
spans.push(Span::styled(
usage,
Style::default().fg(DEFAULT_THEME.help_label),
));
}
ListItem::new(Line::from(spans))
})
.collect();
let item_count = items.len();
let list_height = (item_count as u16).max(1) + 4; // +2 border, +1 filter, +1 separator
let popup_height = list_height.clamp(6, 20);
let area = clamped_centered_rect(50, popup_height, frame.area());
frame.render_widget(Clear, area);
// Split popup area: border top (1) + filter (1) + separator (1) + list + border bottom (1)
let inner = popup_block.inner(area);
frame.render_widget(popup_block, area);
if inner.height < 2 {
return;
}
let inner_layout = Layout::vertical([
Constraint::Length(1), // filter
Constraint::Length(1), // separator
Constraint::Min(1), // list
])
.split(inner);
frame.render_widget(Paragraph::new(filter_line), inner_layout[0]);
frame.render_widget(
Paragraph::new(" ─────────────────────────────────────────────")
.style(Style::default().fg(DEFAULT_THEME.help_label)),
inner_layout[1],
);
// Clamp selection to filtered range
if item_count == 0 {
list_state.select(None);
} else if let Some(sel) = list_state.selected() {
if sel >= item_count {
list_state.select(Some(item_count - 1));
}
} else {
list_state.select(Some(0));
}
let list = List::new(items)
.highlight_symbol("")
.highlight_style(Style::default().bold().reversed());
frame.render_stateful_widget(list, inner_layout[2], list_state);
}
/// Returns the name of the currently selected command (after filtering).
pub fn selected_command<'a>(
commands: &'a [DeviceCommand],
filter: &str,
list_state: &ListState,
) -> Option<&'a DeviceCommand> {
let filtered: Vec<&DeviceCommand> = commands
.iter()
.filter(|cmd| {
if filter.is_empty() {
true
} else {
cmd.name.to_lowercase().contains(&filter.to_lowercase())
}
})
.collect();
list_state.selected().and_then(|i| filtered.get(i).copied())
}
}
fn clamped_centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
let vertical = Layout::vertical([Constraint::Length(h)])
.flex(Flex::Center)
.split(area);
Layout::horizontal([Constraint::Length(w)])
.flex(Flex::Center)
.split(vertical[0])[0]
}

115
src/widgets/graph_view.rs Normal file
View file

@ -0,0 +1,115 @@
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::Style,
widgets::{Block, Borders, Sparkline, Widget},
};
use crate::constants::DEFAULT_THEME;
pub struct GraphView<'a> {
float_data: &'a [f64],
int_data: &'a [i64],
}
impl<'a> GraphView<'a> {
pub fn new(float_data: &'a [f64], int_data: &'a [i64]) -> Self {
Self {
float_data,
int_data,
}
}
fn scale_floats(data: &[f64]) -> Vec<u64> {
if data.is_empty() {
return Vec::new();
}
let min = data.iter().copied().fold(f64::INFINITY, f64::min);
let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let range = max - min;
if range == 0.0 {
return vec![500; data.len()];
}
data.iter()
.map(|&v| ((v - min) / range * 1000.0) as u64)
.collect()
}
fn scale_ints(data: &[i64]) -> Vec<u64> {
if data.is_empty() {
return Vec::new();
}
let min = data.iter().copied().min().unwrap();
let max = data.iter().copied().max().unwrap();
let range = max - min;
if range == 0 {
return vec![500; data.len()];
}
data.iter()
.map(|&v| ((v - min) as f64 / range as f64 * 1000.0) as u64)
.collect()
}
}
impl Widget for GraphView<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let [top, bottom] =
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area);
// Float graph
let float_title = if self.float_data.is_empty() {
" Float (no data) ".to_string()
} else {
let last = self.float_data.last().unwrap();
let min = self
.float_data
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
let max = self
.float_data
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
format!(" Float [last: {last:.2} | min: {min:.2} max: {max:.2}] ")
};
let float_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(DEFAULT_THEME.graph_float_border))
.title(float_title);
let scaled_floats = Self::scale_floats(self.float_data);
Sparkline::default()
.block(float_block)
.data(&scaled_floats)
.max(1000)
.style(Style::default().fg(DEFAULT_THEME.graph_float_border))
.render(top, buf);
// Integer graph
let int_title = if self.int_data.is_empty() {
" Integer (no data) ".to_string()
} else {
let last = self.int_data.last().unwrap();
let min = self.int_data.iter().copied().min().unwrap();
let max = self.int_data.iter().copied().max().unwrap();
format!(" Integer [last: {last} | min: {min} max: {max}] ")
};
let int_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(DEFAULT_THEME.graph_int_border))
.title(int_title);
let scaled_ints = Self::scale_ints(self.int_data);
Sparkline::default()
.block(int_block)
.data(&scaled_ints)
.max(1000)
.style(Style::default().fg(DEFAULT_THEME.graph_int_border))
.render(bottom, buf);
}
}

99
src/widgets/help_bar.rs Normal file
View file

@ -0,0 +1,99 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
text::{Line, Span, Text},
widgets::{Paragraph, Widget},
};
use crate::app::InputMode;
use crate::constants::DEFAULT_THEME;
pub struct HelpBar {
mode: InputMode,
width: u16,
}
impl HelpBar {
pub fn new(mode: InputMode, width: u16) -> Self {
Self { mode, width }
}
}
impl Widget for HelpBar {
fn render(self, area: Rect, buf: &mut Buffer) {
let key = Style::default().fg(DEFAULT_THEME.keybind).bold();
let label = Style::default().fg(DEFAULT_THEME.help_label);
let spans: Vec<Span> = match self.mode {
InputMode::Normal if self.width < 80 => vec![
Span::styled("q", key),
Span::styled(" quit ", label),
Span::styled("e", key),
Span::styled(" edit ", label),
Span::styled("h", key),
Span::styled(" help ", label),
Span::styled("f", key),
Span::styled(" freeze ", label),
Span::styled("1/2/3", key),
Span::styled(" view ", label),
Span::styled("t", key),
Span::styled(" time ", label),
Span::styled("c", key),
Span::styled(" cmds ", label),
Span::styled("s", key),
Span::styled(" sync ", label),
Span::styled("l/L", key),
Span::styled(" export", label),
],
InputMode::Normal => vec![
Span::styled("q", key),
Span::styled(" quit · ", label),
Span::styled("e", key),
Span::styled(" edit · ", label),
Span::styled("h", key),
Span::styled(" help · ", label),
Span::styled("f", key),
Span::styled(" freeze · ", label),
Span::styled("1/2/3", key),
Span::styled(" view · ", label),
Span::styled("t", key),
Span::styled(" time · ", label),
Span::styled("r/R", key),
Span::styled(" clear · ", label),
Span::styled("C-r", key),
Span::styled(" clr graphs · ", label),
Span::styled("c", key),
Span::styled(" cmds · ", label),
Span::styled("s", key),
Span::styled(" sync · ", label),
Span::styled("l/L", key),
Span::styled(" export", label),
],
InputMode::Editing => vec![
Span::styled("Press ", label),
Span::styled("Esc", key),
Span::styled(" to stop editing, ", label),
Span::styled("Enter", key),
Span::styled(" to send the message", label),
],
InputMode::Help => vec![
Span::styled("Press ", label),
Span::styled("Esc", key),
Span::styled(" to close", label),
],
InputMode::CommandPalette => vec![
Span::styled("↑↓", key),
Span::styled(" navigate · ", label),
Span::styled("Enter", key),
Span::styled(" select · ", label),
Span::styled("Esc", key),
Span::styled(" close · ", label),
Span::styled("type to filter", label),
],
};
let text = Text::from(Line::from(spans));
Paragraph::new(text).render(area, buf);
}
}

127
src/widgets/help_popup.rs Normal file
View file

@ -0,0 +1,127 @@
use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Style, Stylize},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Wrap},
};
use crate::constants::DEFAULT_THEME;
pub struct HelpPopup;
impl HelpPopup {
pub fn render_overlay(frame: &mut Frame) {
let key = Style::default().fg(DEFAULT_THEME.keybind).bold();
let label = Style::default().fg(DEFAULT_THEME.help_label);
let help_text = vec![
Line::from("Normal Mode".bold()),
Line::from(vec![
Span::styled(" q", key),
Span::styled(" Quit", label),
]),
Line::from(vec![
Span::styled(" e", key),
Span::styled(" Edit mode", label),
]),
Line::from(vec![
Span::styled(" h", key),
Span::styled(" Toggle help", label),
]),
Line::from(vec![
Span::styled(" f", key),
Span::styled(" Freeze/unfreeze", label),
]),
Line::from(vec![
Span::styled(" 1", key),
Span::styled(" Received messages", label),
]),
Line::from(vec![
Span::styled(" 2", key),
Span::styled(" Sent messages", label),
]),
Line::from(vec![
Span::styled(" 3", key),
Span::styled(" Graphs view", label),
]),
Line::from(vec![
Span::styled(" t", key),
Span::styled(" Toggle timestamps", label),
]),
Line::from(vec![
Span::styled(" r", key),
Span::styled(" Clear received messages", label),
]),
Line::from(vec![
Span::styled(" R", key),
Span::styled(" Clear sent messages", label),
]),
Line::from(vec![
Span::styled(" Ctrl+r", key),
Span::styled(" Clear graphs", label),
]),
Line::from(vec![
Span::styled(" c", key),
Span::styled(" Command palette", label),
]),
Line::from(vec![
Span::styled(" s", key),
Span::styled(" Re-sync commands", label),
]),
Line::from(vec![
Span::styled(" l", key),
Span::styled(" Export current view", label),
]),
Line::from(vec![
Span::styled(" L", key),
Span::styled(" Export all buffers", label),
]),
Line::from(vec![
Span::styled(" x", key),
Span::styled(" Disconnect", label),
]),
Line::from(""),
Line::from("Editing Mode".bold()),
Line::from(vec![
Span::styled(" Esc", key),
Span::styled(" Back to normal", label),
]),
Line::from(vec![
Span::styled(" Enter", key),
Span::styled(" Send message", label),
]),
Line::from(vec![
Span::styled(" Backspace", key),
Span::styled(" Delete character", label),
]),
Line::from(vec![
Span::styled(" ←/→", key),
Span::styled(" Move cursor", label),
]),
];
let popup_block = Block::bordered()
.title("Help".bold())
.border_style(Style::default().fg(DEFAULT_THEME.help_border));
let popup = Paragraph::new(help_text)
.block(popup_block)
.wrap(Wrap { trim: false });
let area = clamped_centered_rect(40, 25, frame.area());
frame.render_widget(Clear, area);
frame.render_widget(popup, area);
}
}
fn clamped_centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
let vertical = Layout::vertical([Constraint::Length(h)])
.flex(Flex::Center)
.split(area);
Layout::horizontal([Constraint::Length(w)])
.flex(Flex::Center)
.split(vertical[0])[0]
}

View file

@ -0,0 +1,41 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Style, Stylize},
widgets::{Block, Paragraph, Widget},
};
use crate::app::InputMode;
use crate::constants::DEFAULT_THEME;
pub struct InputField<'a> {
input: &'a str,
mode: InputMode,
}
impl<'a> InputField<'a> {
pub fn new(input: &'a str, mode: InputMode) -> Self {
Self { input, mode }
}
}
impl Widget for InputField<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = match self.mode {
InputMode::Editing => Block::bordered()
.title("Input".bold())
.border_style(Style::default().fg(DEFAULT_THEME.input_active)),
_ => Block::bordered().title("Input".bold()),
};
let style = match self.mode {
InputMode::Editing => Style::default().fg(DEFAULT_THEME.input_active),
_ => Style::default(),
};
Paragraph::new(self.input)
.style(style)
.block(block)
.render(area, buf);
}
}

View file

@ -0,0 +1,69 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style, Stylize},
text::{Line, Span},
widgets::{Block, List, ListItem, ListState, StatefulWidget},
};
use crate::constants::DEFAULT_THEME;
use crate::serial::SerialMessage;
pub struct MessageList<'a> {
messages: &'a [SerialMessage],
title: &'a str,
show_timestamps: bool,
border_color: Color,
}
impl<'a> MessageList<'a> {
pub fn new(
messages: &'a [SerialMessage],
title: &'a str,
show_timestamps: bool,
border_color: Color,
) -> Self {
Self {
messages,
title,
show_timestamps,
border_color,
}
}
}
impl StatefulWidget for MessageList<'_> {
type State = ListState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let enum_style = Style::default().fg(DEFAULT_THEME.enum_text);
let items: Vec<ListItem> = self
.messages
.iter()
.enumerate()
.map(|(i, m)| {
if self.show_timestamps {
let ts = m.timestamp.format("%H:%M:%S");
ListItem::new(Line::from(vec![
Span::styled(format!("{i}: "), enum_style),
Span::raw(format!("[{ts}] {}", m.message)),
]))
} else {
ListItem::new(Line::from(vec![
Span::styled(format!("{i}: "), enum_style),
Span::raw(&m.message),
]))
}
})
.collect();
let list = List::new(items).block(
Block::bordered()
.title(self.title.bold())
.border_style(Style::default().fg(self.border_color)),
);
StatefulWidget::render(list, area, buf, state);
}
}

15
src/widgets/mod.rs Normal file
View file

@ -0,0 +1,15 @@
mod command_palette;
mod graph_view;
mod help_bar;
mod help_popup;
mod input_field;
mod message_list;
mod status_bar;
pub use command_palette::CommandPalette;
pub use graph_view::GraphView;
pub use help_bar::HelpBar;
pub use help_popup::HelpPopup;
pub use input_field::InputField;
pub use message_list::MessageList;
pub use status_bar::{StatusBar, SyncDisplay};

97
src/widgets/status_bar.rs Normal file
View file

@ -0,0 +1,97 @@
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Paragraph, Widget},
};
use crate::app::InputMode;
use crate::constants::DEFAULT_THEME;
#[derive(Debug, Clone, Copy)]
pub enum SyncDisplay {
/// Initial delay or awaiting begin
Pending,
/// Receiving command list
Receiving,
/// Sync completed with N commands
Synced(usize),
/// Sync failed after retries
Failed,
}
pub struct StatusBar<'a> {
port: &'a str,
baud: u32,
mode: InputMode,
receiving: bool,
sync: SyncDisplay,
}
impl<'a> StatusBar<'a> {
pub fn new(
port: &'a str,
baud: u32,
mode: InputMode,
receiving: bool,
sync: SyncDisplay,
) -> Self {
Self {
port,
baud,
mode,
receiving,
sync,
}
}
}
impl Widget for StatusBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let mode_str = match self.mode {
InputMode::Normal => "NORMAL",
InputMode::Editing => "EDITING",
InputMode::Help => "HELP",
InputMode::CommandPalette => "COMMANDS",
};
let (status_str, status_color) = if self.receiving {
("LIVE", DEFAULT_THEME.status_live)
} else {
("FROZEN", DEFAULT_THEME.status_frozen)
};
let (sync_str, sync_color) = match self.sync {
SyncDisplay::Pending => ("SYNC...".to_string(), Color::Gray),
SyncDisplay::Receiving => ("SYNCING".to_string(), Color::Yellow),
SyncDisplay::Synced(n) => (format!("SYNCED({n})"), crate::constants::SYNC_STATUS_COLOR),
SyncDisplay::Failed => ("SYNC FAILED".to_string(), Color::Red),
};
let left = Span::raw(format!(" {} @ {} baud", self.port, self.baud));
let right_spans = vec![
Span::styled(&sync_str, Style::default().fg(sync_color).bold()),
Span::raw(" | "),
Span::styled(mode_str, Style::default().bold()),
Span::raw(" | "),
Span::styled(status_str, Style::default().fg(status_color).bold()),
Span::raw(" "),
];
// Fill background
Paragraph::new("")
.style(Style::default().bg(DEFAULT_THEME.status_bar_bg))
.render(area, buf);
// Left-aligned text
Paragraph::new(left)
.style(Style::default().bg(DEFAULT_THEME.status_bar_bg))
.render(area, buf);
// Right-aligned text
Paragraph::new(Line::from(right_spans))
.alignment(Alignment::Right)
.style(Style::default().bg(DEFAULT_THEME.status_bar_bg))
.render(area, buf);
}
}