initial commit
This commit is contained in:
commit
ff2a0367d5
22 changed files with 4338 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
2021
Cargo.lock
generated
Normal file
2021
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
12
Cargo.toml
Normal file
12
Cargo.toml
Normal 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
115
src/app/export.rs
Normal 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
45
src/app/input.rs
Normal 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
168
src/app/mod.rs
Normal 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
122
src/app/render.rs
Normal 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
153
src/app/serial_handler.rs
Normal 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
234
src/app/state.rs
Normal 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
68
src/constants.rs
Normal 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
64
src/main.rs
Normal 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
284
src/serial.rs
Normal 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
309
src/setup.rs
Normal 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
34
src/sync.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
145
src/widgets/command_palette.rs
Normal file
145
src/widgets/command_palette.rs
Normal 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
115
src/widgets/graph_view.rs
Normal 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
99
src/widgets/help_bar.rs
Normal 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
127
src/widgets/help_popup.rs
Normal 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]
|
||||
}
|
||||
41
src/widgets/input_field.rs
Normal file
41
src/widgets/input_field.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
69
src/widgets/message_list.rs
Normal file
69
src/widgets/message_list.rs
Normal 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
15
src/widgets/mod.rs
Normal 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
97
src/widgets/status_bar.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue