From 6e639313e8627582e43be556a5ac9e4923b22a3a Mon Sep 17 00:00:00 2001 From: lenn Date: Fri, 24 Apr 2026 02:58:44 +0800 Subject: [PATCH] Add TUI serial collector and export commands --- src/app.rs | 284 +++++++++-- src/cmd.rs | 355 ++++++++++---- src/flog.rs | 53 +-- src/main.rs | 7 +- src/serial_core/codecs/mod.rs | 4 +- src/serial_core/codecs/tactile_a.rs | 42 +- src/serial_core/codecs/test.rs | 56 ++- src/serial_core/error.rs | 4 + src/serial_core/frame.rs | 11 +- src/serial_core/mod.rs | 1 - src/serial_core/record.rs | 14 +- src/serial_core/serial.rs | 16 +- src/serial_core/utils.rs | 25 +- src/tui.rs | 706 ++++++++++++++++++++++++++++ 14 files changed, 1330 insertions(+), 248 deletions(-) create mode 100644 src/tui.rs diff --git a/src/app.rs b/src/app.rs index 1281b9b..81e0e3b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,12 @@ +use anyhow::{Context, anyhow}; +use chrono::Local; use log::error; use std::{ + collections::HashMap, + env, + fs::{self, File}, + io::BufWriter, + path::{Path, PathBuf}, sync::{Arc, Mutex}, time::{Duration, Instant}, }; @@ -9,7 +16,7 @@ use tokio_util::sync::CancellationToken; use crate::serial_core::{ TactileARecording, - codecs::tactile_a::{TactileACodec, TactileAHandler}, + codecs::tactile_a::{TactileACodec, TactileAHandler, export_recording_csv}, error::SerialError, frame::TactileAFrame, serial::{self, PollMode, SerialFrame, TactileAPollRequester}, @@ -38,28 +45,124 @@ impl SerialSession { } pub struct SerialConnectionState { - session: Mutex>>, - last_record: Mutex>>>, + sessions: Mutex>>, + last_records: Mutex>>>, + export_dir: Mutex, } impl SerialConnectionState { pub fn new() -> Self { Self { - session: Mutex::new(None), - last_record: Mutex::new(None), + sessions: Mutex::new(HashMap::new()), + last_records: Mutex::new(HashMap::new()), + export_dir: Mutex::new(default_export_dir()), } } - pub fn current_port(&self) -> Result, SerialError> { - let session = self.session.lock().map_err(|_| SerialError::StateError)?; - Ok(session.as_ref().map(|session| session.port.clone())) + pub fn active_ports(&self) -> Result, SerialError> { + let sessions = self.sessions.lock().map_err(|_| SerialError::StateError)?; + let mut ports = sessions.keys().cloned().collect::>(); + ports.sort(); + Ok(ports) } - pub fn current_record(&self) -> Result>>, SerialError> { - let session = self.session.lock().map_err(|_| SerialError::StateError)?; - Ok(session - .as_ref() - .map(|session| Arc::clone(&session.current_record))) + pub fn collector_lines(&self) -> Result, SerialError> { + let ports = self.active_ports()?; + if ports.is_empty() { + return Ok(vec![ + "No active serial collectors.".to_string(), + "Use /open to start collecting.".to_string(), + ]); + } + + Ok(ports + .into_iter() + .map(|port| format!("Serial {port} is collecting...")) + .collect()) + } + + pub fn exportable_ports(&self) -> Result, SerialError> { + let active_ports = self.active_ports()?; + let last_records = self + .last_records + .lock() + .map_err(|_| SerialError::StateError)?; + let mut ports = active_ports; + ports.extend(last_records.keys().cloned()); + ports.sort(); + ports.dedup(); + Ok(ports) + } + + pub fn current_export_dir(&self) -> Result { + let export_dir = self + .export_dir + .lock() + .map_err(|_| SerialError::StateError)?; + Ok(export_dir.clone()) + } + + pub fn set_export_dir(&self, path: &str) -> Result { + let export_dir = normalize_export_dir(path)?; + let mut current = self + .export_dir + .lock() + .map_err(|_| SerialError::StateError)?; + *current = export_dir.clone(); + Ok(export_dir) + } + + pub fn export_port_recording(&self, port: &str) -> anyhow::Result { + let port_name = port.trim(); + if port_name.is_empty() { + return Err(anyhow!(SerialError::InvalidConfig)); + } + + let recording_handle = { + let sessions = self + .sessions + .lock() + .map_err(|_| anyhow!(SerialError::StateError))?; + if let Some(session) = sessions.get(port_name) { + Arc::clone(&session.current_record) + } else { + drop(sessions); + let last_records = self + .last_records + .lock() + .map_err(|_| anyhow!(SerialError::StateError))?; + last_records + .get(port_name) + .cloned() + .ok_or_else(|| anyhow!(SerialError::NoRecordedData))? + } + }; + + let recording = recording_handle + .lock() + .map_err(|_| anyhow!(SerialError::StateError))? + .clone(); + + if recording.frames.is_empty() { + return Err(anyhow!(SerialError::NoRecordedData)); + } + + let export_dir = self + .current_export_dir() + .map_err(|err| anyhow!(err)) + .context("failed to read export directory")?; + fs::create_dir_all(&export_dir).with_context(|| { + format!("failed to create export directory {}", export_dir.display()) + })?; + + let output_path = export_dir.join(export_file_name(port_name)); + let file = File::create(&output_path) + .with_context(|| format!("failed to create {}", output_path.display()))?; + let writer = BufWriter::new(file); + export_recording_csv(&recording, writer) + .with_context(|| format!("failed to export {}", output_path.display()))?; + + Ok(output_path) } } @@ -88,8 +191,8 @@ pub async fn serial_connect( } { - let session = state.session.lock().map_err(|_| SerialError::StateError)?; - if session.is_some() { + let sessions = state.sessions.lock().map_err(|_| SerialError::StateError)?; + if sessions.contains_key(&port_name) { return Err(SerialError::AlreadyConnected); } } @@ -100,28 +203,31 @@ pub async fn serial_connect( let cancel = CancellationToken::new(); let current_record = Arc::new(Mutex::new(TactileARecording::new())); - let session_started_at = Instant::now(); let session = Arc::new(SerialSession::new( - port_name, + port_name.clone(), Arc::clone(¤t_record), cancel.clone(), )); { - let mut active_session = state.session.lock().map_err(|_| SerialError::StateError)?; - *active_session = Some(Arc::clone(&session)); - } - { - let mut last_record = state - .last_record - .lock() - .map_err(|_| SerialError::StateError)?; - *last_record = None; + let mut sessions = state.sessions.lock().map_err(|_| SerialError::StateError)?; + if sessions.contains_key(&port_name) { + return Err(SerialError::AlreadyConnected); + } + sessions.insert(port_name.clone(), Arc::clone(&session)); } + state + .last_records + .lock() + .map_err(|_| SerialError::StateError)? + .remove(&port_name); + let task_state = Arc::clone(&state); let task_session = Arc::clone(&session); let task_record = Arc::clone(¤t_record); + let task_port = port_name.clone(); + let session_started_at = Instant::now(); let task = tokio::spawn(async move { let codec = TactileACodec::new(7, 12); @@ -144,19 +250,19 @@ pub async fn serial_connect( ) .await { - error!("serial task exited with error: {err}"); + error!("serial task exited with error on {task_port}: {err}"); } - if let Ok(mut last_record) = task_state.last_record.lock() { - *last_record = Some(Arc::clone(&task_record)); + if let Ok(mut last_records) = task_state.last_records.lock() { + last_records.insert(task_port.clone(), Arc::clone(&task_record)); } - if let Ok(mut active_session) = task_state.session.lock() { - let should_clear = active_session - .as_ref() + if let Ok(mut sessions) = task_state.sessions.lock() { + let should_clear = sessions + .get(&task_port) .map(|session| Arc::ptr_eq(session, &task_session)) .unwrap_or(false); if should_clear { - *active_session = None; + sessions.remove(&task_port); } } if let Ok(mut task_slot) = task_session.task.lock() { @@ -170,20 +276,53 @@ pub async fn serial_connect( Ok(()) } -pub async fn serial_disconnect(state: Arc) -> Result<(), SerialError> { - shutdown_active_session(&state).await -} +pub async fn serial_disconnect_port( + port: &str, + state: &SerialConnectionState, +) -> Result<(), SerialError> { + let port_name = port.trim(); + if port_name.is_empty() { + return Err(SerialError::InvalidConfig); + } -pub async fn shutdown_active_session(state: &SerialConnectionState) -> Result<(), SerialError> { let session = { - let mut active_session = state.session.lock().map_err(|_| SerialError::StateError)?; - active_session.take() + let mut sessions = state.sessions.lock().map_err(|_| SerialError::StateError)?; + sessions.remove(port_name) }; let Some(session) = session else { - return Ok(()); + return Err(SerialError::NotConnected); }; + shutdown_session(state, session).await +} + +pub async fn shutdown_all_sessions( + state: &SerialConnectionState, +) -> Result, SerialError> { + let sessions = { + let mut sessions = state.sessions.lock().map_err(|_| SerialError::StateError)?; + let drained = sessions + .drain() + .map(|(_, session)| session) + .collect::>(); + drained + }; + + let mut closed_ports = Vec::with_capacity(sessions.len()); + for session in sessions { + closed_ports.push(session.port.clone()); + shutdown_session(state, session).await?; + } + + closed_ports.sort(); + Ok(closed_ports) +} + +async fn shutdown_session( + state: &SerialConnectionState, + session: Arc, +) -> Result<(), SerialError> { session.cancel.cancel(); let task = { @@ -195,11 +334,70 @@ pub async fn shutdown_active_session(state: &SerialConnectionState) -> Result<() task.await.map_err(|_| SerialError::CloseError)?; } - let mut last_record = state - .last_record + let mut last_records = state + .last_records .lock() .map_err(|_| SerialError::StateError)?; - *last_record = Some(Arc::clone(&session.current_record)); + last_records.insert(session.port.clone(), Arc::clone(&session.current_record)); Ok(()) } + +fn default_export_dir() -> PathBuf { + let base = if cfg!(windows) { + env::var_os("USERPROFILE") + .map(PathBuf::from) + .or_else(|| env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) + } else { + env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) + }; + + base.join("Desktop") +} + +fn normalize_export_dir(path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(SerialError::InvalidConfig); + } + + let path = Path::new(trimmed); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + }; + + Ok(resolved) +} + +fn export_file_name(port: &str) -> String { + let sanitized_port = sanitize_file_component(port); + let timestamp = Local::now().format("%Y%m%d_%H%M%S"); + format!("tactile_a_{sanitized_port}_{timestamp}.csv") +} + +fn sanitize_file_component(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '_' + } + }) + .collect::(); + let trimmed = sanitized.trim_matches('_'); + if trimmed.is_empty() { + "serial".to_string() + } else { + trimmed.to_string() + } +} diff --git a/src/cmd.rs b/src/cmd.rs index accab3b..0c3e18c 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -1,115 +1,268 @@ -use std::io::{self, Stdout, stdout}; +use std::sync::Arc; -use anyhow::Ok; -use crossterm::{ - event::KeyCode, - execute, - terminal::{ - self, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, - }, -}; -use ratatui::{ - Terminal, - backend::{self, CrosstermBackend}, - layout::{Constraint, Direction, Layout, Margin}, - style::{Modifier, Style}, - widgets::{Block, Borders, Paragraph, Wrap}, -}; -use ratatui_textarea::TextArea; +use anyhow::{Result, bail}; -#[derive(Debug, Default)] -struct App { - messages: Vec, - should_quit: bool, - input: TextArea<'static>, +use crate::{ + app::{SerialConnectionState, serial_connect, serial_disconnect_port, shutdown_all_sessions}, + serial_core::{error::SerialError, utils::serial_enum}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + Help, + Exit, + Scan, + Status, + Echo(String), + Open(String), + Close(Option), + Export(String), + SetExport(String), + Unknown(String), } -impl App { - fn new() -> Self { - let mut input = TextArea::default(); - input.set_block(Block::default().borders(Borders::ALL).title("Input")); - input.set_cursor_line_style(Style::default().add_modifier(Modifier::REVERSED)); - Self { - messages: vec!["Welcome to JE-Skin-Cli".to_string()], - should_quit: false, - input, - } +#[derive(Debug, Clone)] +pub struct CommandResponse { + pub lines: Vec, + pub should_exit: bool, +} + +impl CommandResponse { + fn new(lines: Vec, should_exit: bool) -> Self { + Self { lines, should_exit } } - fn on_key(&mut self, code: KeyCode) { - match code { - KeyCode::Esc => self.should_quit = true, - KeyCode::Enter => { - let text = self.input.lines().join("\n"); - if !text.trim().is_empty() { - self.messages.push(format!("You send: {}", text.trim())); + fn from_line(line: impl Into) -> Self { + Self::new(vec![line.into()], false) + } +} + +pub fn parse_command(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + bail!("empty command"); + } + if !input.starts_with('/') { + bail!("commands must start with '/'"); + } + + let body = &input[1..]; + let mut parts = body.splitn(2, ' '); + let name = parts.next().unwrap_or("").trim().to_ascii_lowercase(); + let rest = parts.next().unwrap_or("").trim(); + + let command = match name.as_str() { + "help" => Command::Help, + "exit" | "quit" => Command::Exit, + "scan" => Command::Scan, + "status" => Command::Status, + "echo" => Command::Echo(rest.to_string()), + "open" => { + if rest.is_empty() { + bail!("/open requires a serial port path"); + } + Command::Open(rest.to_string()) + } + "close" => Command::Close(if rest.is_empty() { + None + } else { + Some(rest.to_string()) + }), + "export" => { + if rest.is_empty() { + bail!("/export requires a serial port path"); + } + Command::Export(rest.to_string()) + } + "set" => { + let mut setting_parts = rest.splitn(2, ' '); + let setting_name = setting_parts + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + let setting_value = setting_parts.next().unwrap_or("").trim(); + + match setting_name.as_str() { + "export" => { + if setting_value.is_empty() { + bail!("/set export requires a directory path"); + } + Command::SetExport(setting_value.to_string()) } - self.input = { - let mut ta = TextArea::default(); - ta.set_block(Block::default().borders(Borders::ALL).title("Input")); - ta.set_cursor_line_style(Style::default().add_modifier(Modifier::REVERSED)); - ta - }; - } - _ => { - self.input.input(match code { - KeyCode::Char(c) => c.into(), - KeyCode::Backspace => ratatui_textarea::Input { - key: ratatui_textarea::Key::Backspace, - ctrl: false, - alt: false, - shift: false, - }, - KeyCode::Left => ratatui_textarea::Input { - key: ratatui_textarea::Key::Left, - ctrl: false, - alt: false, - shift: false, - }, - KeyCode::Right => ratatui_textarea::Input { - key: ratatui_textarea::Key::Right, - ctrl: false, - alt: false, - shift: false, - }, - KeyCode::Up => ratatui_textarea::Input { - key: ratatui_textarea::Key::Up, - ctrl: false, - alt: false, - shift: false, - }, - KeyCode::Down => ratatui_textarea::Input { - key: ratatui_textarea::Key::Down, - ctrl: false, - alt: false, - shift: false, - }, - _ => return, - }); + _ => bail!("unknown setting: {setting_name}"), } } + other => Command::Unknown(other.to_string()), + }; + + Ok(command) +} + +pub async fn execute_input(input: &str, state: Arc) -> CommandResponse { + let command = match parse_command(input) { + Ok(command) => command, + Err(err) => return CommandResponse::from_line(format!("Error: {err}")), + }; + + execute_command(command, state).await +} + +pub async fn execute_command( + command: Command, + state: Arc, +) -> CommandResponse { + match command { + Command::Help => CommandResponse::new(help_lines(), false), + Command::Exit => CommandResponse::new(vec!["bye".to_string()], true), + Command::Scan => match serial_enum() { + Ok(ports) if ports.is_empty() => { + CommandResponse::from_line("No serial ports found".to_string()) + } + Ok(ports) => CommandResponse::new( + ports + .into_iter() + .enumerate() + .map(|(index, port)| format!("{index}. {port}")) + .collect(), + false, + ), + Err(err) => CommandResponse::from_line(format!("Scan ports failed: {err}")), + }, + Command::Status => match state.collector_lines() { + Ok(mut lines) => { + match state.current_export_dir() { + Ok(export_dir) => { + lines.push(format!("Export directory: {}", export_dir.display())) + } + Err(err) => lines.push(format!("Read export directory failed: {err}")), + } + CommandResponse::new(lines, false) + } + Err(err) => CommandResponse::from_line(format!("Read collector status failed: {err}")), + }, + Command::Echo(text) => CommandResponse::from_line(text), + Command::Open(port) => match serial_connect(port.clone(), Arc::clone(&state)).await { + Ok(()) => CommandResponse::from_line(format!("Serial {port} is collecting...")), + Err(err) => CommandResponse::from_line(open_error_message(&port, err)), + }, + Command::Close(Some(port)) => match serial_disconnect_port(&port, state.as_ref()).await { + Ok(()) => CommandResponse::from_line(format!("Serial {port} stopped collecting.")), + Err(SerialError::NotConnected) => { + CommandResponse::from_line(format!("Serial {port} is not collecting.")) + } + Err(err) => { + CommandResponse::from_line(format!("Close serial port failed for {port}: {err}")) + } + }, + Command::Close(None) => match shutdown_all_sessions(state.as_ref()).await { + Ok(ports) if ports.is_empty() => { + CommandResponse::from_line("No active serial collectors.".to_string()) + } + Ok(ports) => CommandResponse::new( + ports + .into_iter() + .map(|port| format!("Serial {port} stopped collecting.")) + .collect(), + false, + ), + Err(err) => { + CommandResponse::from_line(format!("Close all serial collectors failed: {err}")) + } + }, + Command::Export(port) => match state.export_port_recording(&port) { + Ok(output_path) => CommandResponse::new( + vec![ + format!("Exported serial {port}."), + format!("Output: {}", output_path.display()), + ], + false, + ), + Err(err) => CommandResponse::from_line(format!("Export failed for {port}: {err}")), + }, + Command::SetExport(path) => match state.set_export_dir(&path) { + Ok(export_dir) => CommandResponse::new( + vec![ + "Export directory updated.".to_string(), + format!("Current export directory: {}", export_dir.display()), + ], + false, + ), + Err(err) => CommandResponse::from_line(format!("Set export directory failed: {err}")), + }, + Command::Unknown(name) => CommandResponse::new( + vec![ + format!("Unknown command: {name}"), + "Use /help to list available commands".to_string(), + ], + false, + ), } } -pub fn init_terminal() -> Result>> { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let terminal = Terminal::new(backend)?; - Ok(terminal) -} - -pub fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - Ok(()) -} - -fn run_app(terminal: &mut Terminal>) -> Result<()> { - let mut app = App::new(); - while !app.should_quit { - terminal.draw(|f| {}) +fn open_error_message(port: &str, err: SerialError) -> String { + match err { + SerialError::AlreadyConnected => format!("Serial {port} is already collecting."), + other => format!("Open serial port failed for {port}: {other}"), + } +} + +fn help_lines() -> Vec { + vec![ + "Available commands:".to_string(), + " /help Show help".to_string(), + " /scan List serial ports".to_string(), + " /status Show active collectors and export directory".to_string(), + " /open Start collecting on a serial port".to_string(), + " /close Stop collecting on one serial port".to_string(), + " /close Stop collecting on all serial ports".to_string(), + " /export Export one serial recording to CSV".to_string(), + " /set export Set the export directory".to_string(), + " /echo Print text to the left pane".to_string(), + " /exit Exit the TUI".to_string(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_open_command() { + assert_eq!( + parse_command("/open /dev/ttyUSB0").unwrap(), + Command::Open("/dev/ttyUSB0".to_string()) + ); + } + + #[test] + fn parse_close_without_argument() { + assert_eq!(parse_command("/close").unwrap(), Command::Close(None)); + } + + #[test] + fn parse_status_command() { + assert_eq!(parse_command("/status").unwrap(), Command::Status); + } + + #[test] + fn parse_export_command() { + assert_eq!( + parse_command("/export /dev/ttyUSB0").unwrap(), + Command::Export("/dev/ttyUSB0".to_string()) + ); + } + + #[test] + fn parse_set_export_command() { + assert_eq!( + parse_command("/set export ./exports").unwrap(), + Command::SetExport("./exports".to_string()) + ); + } + + #[test] + fn reject_non_command_input() { + assert!(parse_command("scan").is_err()); } } diff --git a/src/flog.rs b/src/flog.rs index d9bf659..d642c70 100644 --- a/src/flog.rs +++ b/src/flog.rs @@ -1,5 +1,8 @@ -use fern::{Dispatch, colors::{ColoredLevelConfig, Color}, DateBased}; -use log::{debug}; +use fern::{ + DateBased, Dispatch, + colors::{Color, ColoredLevelConfig}, +}; +use log::debug; use std::time::SystemTime; pub fn setup_logger() { let colors_line = ColoredLevelConfig::new() @@ -16,10 +19,9 @@ pub fn setup_logger() { log::LevelFilter::Info }; - let console_config = fern::Dispatch::new() - .format(move |out, message, record| { - out.finish( - format_args!( + let console_config = fern::Dispatch::new() + .format(move |out, message, record| { + out.finish(format_args!( "{colors_line}[{data} {level} {target} {colors_line}] {message}\x1B[0m", colors_line = format_args!( "\x1B[{}m", @@ -29,30 +31,27 @@ pub fn setup_logger() { target = record.target(), level = colors_level.color(record.level()), message = message, - ) - ); - }) - .level(level) - .chain(std::io::stdout()); - + )); + }) + .level(level) + .chain(std::io::stdout()); + let data_based_config = fern::Dispatch::new() .format(move |out, message, record| { - out.finish( - format_args!( - "[{data} {level} {target}] {message}", - data = humantime::format_rfc3339_seconds(SystemTime::now()), - target = record.target(), - level = colors_level.color(record.level()), - message = message, - ) - ); + out.finish(format_args!( + "[{data} {level} {target}] {message}", + data = humantime::format_rfc3339_seconds(SystemTime::now()), + target = record.target(), + level = colors_level.color(record.level()), + message = message, + )); }) .level(level) .chain(fern::DateBased::new("program.log", "%Y-%m-%d")); Dispatch::new() - .level(log::LevelFilter::Debug) - .chain(console_config) - .chain(data_based_config) - .apply() - .unwrap(); -} \ No newline at end of file + .level(log::LevelFilter::Debug) + .chain(console_config) + .chain(data_based_config) + .apply() + .unwrap(); +} diff --git a/src/main.rs b/src/main.rs index 89dafd0..9c7363b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,9 @@ pub mod app; pub mod cmd; pub mod flog; pub mod serial_core; -fn main() { - println!("Hello, world!"); +pub mod tui; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tui::run().await } diff --git a/src/serial_core/codecs/mod.rs b/src/serial_core/codecs/mod.rs index d4b0944..302ede3 100644 --- a/src/serial_core/codecs/mod.rs +++ b/src/serial_core/codecs/mod.rs @@ -1,5 +1,5 @@ use crate::serial_core::{frame::TestFrame, record::Recording}; -pub mod test; pub mod tactile_a; -pub type TestRecording = Recording; \ No newline at end of file +pub mod test; +pub type TestRecording = Recording; diff --git a/src/serial_core/codecs/tactile_a.rs b/src/serial_core/codecs/tactile_a.rs index a8bcee0..226ddf8 100644 --- a/src/serial_core/codecs/tactile_a.rs +++ b/src/serial_core/codecs/tactile_a.rs @@ -2,17 +2,17 @@ use crate::serial_core::error::CodecError; use crate::serial_core::frame::{ FrameHandler, TactileAFrameMetaData, TactileARepFrame, TactileAReqFrame, }; -use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording}; +use crate::serial_core::record::{CsvExporter, CsvImporter, RecordedFrame, Recording, write_csv}; use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis}; use crate::serial_core::{ codec::Codec, frame::{TactileAFrame, TactileAFrameStatusCode}, }; +use anyhow::anyhow; use async_trait::async_trait; use csv::StringRecord; -use anyhow::anyhow; -use std::io::Read; use log::debug; +use std::io::Read; const FRAME_BUFFER_MIN_LENGTH: usize = 15; @@ -77,11 +77,7 @@ impl TactileACodec { .chunks_exact(2) .map(|chunk| { let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32; - if raw < 15 { - 0 - } else { - raw - } + if raw < 15 { 0 } else { raw } }) .collect::>(); @@ -223,14 +219,13 @@ impl Codec for TactileACodec { req_bytes.push(f.meta.func_code); req_bytes.extend_from_slice(f.meta.start_addr.to_le_bytes().as_slice()); - req_bytes.extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice()); + req_bytes + .extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice()); let checksum = calc_crc8_itu(req_bytes.as_slice()); req_bytes.push(checksum); Ok(req_bytes) } - _ => { - Err(CodecError::InvalidFrameType) - } + _ => Err(CodecError::InvalidFrameType), } } } @@ -267,10 +262,7 @@ impl CsvExporter for TactileACsvExporter { header } - fn csv_row( - &self, - item: &RecordedFrame, - ) -> anyhow::Result> { + fn csv_row(&self, item: &RecordedFrame) -> anyhow::Result> { let packet = TactileADataPacket::try_from(&item.frame)?; let summary: i32 = packet.data.iter().sum(); let mut row: Vec = packet.data.iter().map(|x| x.to_string()).collect(); @@ -293,13 +285,12 @@ impl CsvExporter for TactileACsvExporter { header } - fn csv_row( - &self, - item: &RecordedFrame, - ) -> anyhow::Result> { + fn csv_row(&self, item: &RecordedFrame) -> anyhow::Result> { let rep = match &item.frame { TactileAFrame::Rep(rep) => rep, - TactileAFrame::Req(_) => return Err(anyhow!("request frame cannot be exported to csv row")), + TactileAFrame::Req(_) => { + return Err(anyhow!("request frame cannot be exported to csv row")); + } }; let packet = TactileADataPacket::try_from(rep)?; @@ -329,7 +320,9 @@ impl TactileACsvImporter { let mut data = Vec::with_capacity(self.channels); for index in 0..self.channels { - let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?; + let cell = record + .get(index) + .ok_or_else(|| anyhow!("missing channel cell"))?; data.push(cell.parse::()?); } @@ -364,7 +357,10 @@ impl CsvImporter for TactileACsvImporter { } } -pub fn export_recording_csv(recording: &Recording, writer: W) -> anyhow::Result<()> +pub fn export_recording_csv( + recording: &Recording, + writer: W, +) -> anyhow::Result<()> where W: std::io::Write, { diff --git a/src/serial_core/codecs/test.rs b/src/serial_core/codecs/test.rs index ad4fc60..0d98733 100644 --- a/src/serial_core/codecs/test.rs +++ b/src/serial_core/codecs/test.rs @@ -1,15 +1,12 @@ -use std::io::Read; -use std::time::Instant; -use crate::serial_core::frame::{FrameHandler}; +use crate::serial_core::frame::FrameHandler; +use crate::serial_core::record::{CsvExporter, CsvImporter, RecordedFrame, Recording, write_csv}; +use crate::serial_core::utils::{elapsed_millis, usize_to_u16_be_bytes}; use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame}; use anyhow::anyhow; use async_trait::async_trait; use csv::StringRecord; -use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording}; -use crate::serial_core::utils::{ - elapsed_millis, - usize_to_u16_be_bytes -}; +use std::io::Read; +use std::time::Instant; pub struct TestCodec { buffer: Vec, } @@ -23,7 +20,11 @@ impl TestCodec { } impl Codec for TestCodec { - fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result, CodecError> { + fn decode( + &mut self, + input: &[u8], + session_started_at: Instant, + ) -> Result, CodecError> { self.buffer.extend_from_slice(input); let mut frames = Vec::new(); @@ -126,7 +127,7 @@ pub struct TestCsvImporter { #[derive(Clone)] pub struct TestDataPacket { pub data: Vec, - pub dts_ms: u64 + pub dts_ms: u64, } impl TryFrom<&TestFrame> for TestDataPacket { @@ -134,7 +135,10 @@ impl TryFrom<&TestFrame> for TestDataPacket { fn try_from(frame: &TestFrame) -> Result { let data = parse_data_frame(&frame.payload)?; let dts = frame.dts_ms; - Ok(TestDataPacket { data: data, dts_ms: dts }) + Ok(TestDataPacket { + data: data, + dts_ms: dts, + }) } } // impl From for TestDataPacket { @@ -145,14 +149,17 @@ impl TryFrom<&TestFrame> for TestDataPacket { // } // } - impl CsvExporter for TestCsvExporter { type Error = CodecError; fn csv_header(&self, recording: &Recording) -> Vec { let channel_nb = recording .frames .iter() - .find_map(|frame| parse_data_frame(&frame.frame.payload).ok().map(|vals| vals.len())) + .find_map(|frame| { + parse_data_frame(&frame.frame.payload) + .ok() + .map(|vals| vals.len()) + }) .unwrap_or(0); let mut header: Vec = Vec::new(); for i in 0..channel_nb { @@ -180,7 +187,7 @@ impl TestCsvImporter { } } - fn parse_record(&mut self, record: StringRecord) -> anyhow::Result{ + fn parse_record(&mut self, record: StringRecord) -> anyhow::Result { if self.channels == 0 { return Err(anyhow!("csv header is missing channel columns")); } @@ -191,7 +198,9 @@ impl TestCsvImporter { let mut data = Vec::with_capacity(self.channels); for index in 0..self.channels { - let cell = record.get(index).ok_or_else(|| anyhow!("missing channel cell"))?; + let cell = record + .get(index) + .ok_or_else(|| anyhow!("missing channel cell"))?; data.push(cell.parse::()?); } @@ -226,7 +235,6 @@ impl CsvImporter for TestCsvImporter { } } - pub fn export_recording_csv(recording: &Recording, writer: W) -> anyhow::Result<()> where W: std::io::Write, @@ -237,19 +245,19 @@ where #[cfg(test)] mod tests { use super::*; - use csv::Reader; use std::io::Cursor; #[test] fn test_read_csv_basic() -> anyhow::Result<()> { - let mut rdr = Reader::from_path("recording_20260329_125238.csv")?; - let headers = rdr.headers()?; - println!("headers: {:?}", headers); + let csv = "channel1,channel2,dts\n10,20,5\n30,40,15\n"; + let mut importer = TestCsvImporter::new(""); + let packets = importer.load(Cursor::new(csv))?; - for result in rdr.records() { - let record = result?; - println!("record: {:?}", record); - } + assert_eq!(packets.len(), 2); + assert_eq!(packets[0].data, vec![10, 20]); + assert_eq!(packets[0].dts_ms, 5); + assert_eq!(packets[1].data, vec![30, 40]); + assert_eq!(packets[1].dts_ms, 15); Ok(()) } diff --git a/src/serial_core/error.rs b/src/serial_core/error.rs index fae6738..71a21fe 100644 --- a/src/serial_core/error.rs +++ b/src/serial_core/error.rs @@ -8,6 +8,7 @@ pub enum SerialError { ScanError, InvalidConfig, AlreadyConnected, + NotConnected, StateError, NoRecordedData, ExportError, @@ -22,6 +23,7 @@ impl fmt::Display for SerialError { SerialError::ScanError => write!(f, "Scan Error"), SerialError::InvalidConfig => write!(f, "Invalid Config"), SerialError::AlreadyConnected => write!(f, "Already Connected"), + SerialError::NotConnected => write!(f, "Not Connected"), SerialError::StateError => write!(f, "State Error"), SerialError::NoRecordedData => write!(f, "No Recorded Data"), SerialError::ExportError => write!(f, "Export Error"), @@ -30,6 +32,8 @@ impl fmt::Display for SerialError { } } +impl std::error::Error for SerialError {} + #[derive(Debug)] pub enum CodecError { InvalidHeader, diff --git a/src/serial_core/frame.rs b/src/serial_core/frame.rs index 42d23a6..ceb57e8 100644 --- a/src/serial_core/frame.rs +++ b/src/serial_core/frame.rs @@ -7,7 +7,7 @@ pub struct TestFrame { pub length: usize, pub payload: Vec, pub checksum: u8, - pub dts_ms: u64 + pub dts_ms: u64, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -30,28 +30,27 @@ pub struct TactileAReqFrame { pub meta: TactileAFrameMetaData, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct TactileARepFrame { pub meta: TactileAFrameMetaData, pub status: TactileAFrameStatusCode, pub payload: Vec, - pub dts_ms: u64 + pub dts_ms: u64, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum TactileAFrameStatusCode { Success, - Failure + Failure, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum TactileAFrame { Req(TactileAReqFrame), - Rep(TactileARepFrame) + Rep(TactileARepFrame), } #[async_trait] pub trait FrameHandler: Send { async fn on_frame(&mut self, frame: &F) -> Result>>; } - diff --git a/src/serial_core/mod.rs b/src/serial_core/mod.rs index 05298ea..8ae46d6 100644 --- a/src/serial_core/mod.rs +++ b/src/serial_core/mod.rs @@ -42,4 +42,3 @@ pub fn serial_enum() -> Result, SerialError> { Ok(ports) } - diff --git a/src/serial_core/record.rs b/src/serial_core/record.rs index 7a20d35..f40c765 100644 --- a/src/serial_core/record.rs +++ b/src/serial_core/record.rs @@ -7,16 +7,18 @@ pub struct FrameTiming { #[derive(Clone)] pub struct RecordedFrame { pub timing: FrameTiming, - pub frame: F + pub frame: F, } #[derive(Clone, Default)] pub struct Recording { - pub frames: Vec> + pub frames: Vec>, } impl Recording { - pub fn new() -> Recording { Self { frames: Vec::new() } } + pub fn new() -> Recording { + Self { frames: Vec::new() } + } pub fn push(&mut self, ite: RecordedFrame) { self.frames.push(ite); } @@ -33,11 +35,7 @@ pub trait CsvImporter

{ fn load(&mut self, reader: R) -> anyhow::Result>; } -pub fn write_csv( - recording: &Recording, - exporter: &E, - writer: W, -) -> anyhow::Result<()> +pub fn write_csv(recording: &Recording, exporter: &E, writer: W) -> anyhow::Result<()> where E: CsvExporter, W: std::io::Write, diff --git a/src/serial_core/serial.rs b/src/serial_core/serial.rs index 4b9a90e..bb302fc 100644 --- a/src/serial_core/serial.rs +++ b/src/serial_core/serial.rs @@ -155,8 +155,6 @@ where }); let mut buffer = [0u8; 1024]; - let mut prune_interval = time::interval(Duration::from_millis(450)); - prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); loop { tokio::select! { @@ -193,17 +191,21 @@ where r.on_rx_frame(&frame); } - let decode_res = handler + let _decoded_values = handler .on_frame(&frame) .await? .map(|vals| vals.into_iter().map(Into::into).collect::>()); - let mut record = recording.lock().map_err(|_| anyhow::anyhow!("recording state poisoned"))?; - record.push(RecordedFrame{ - timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() }, + let mut record = recording + .lock() + .map_err(|_| anyhow::anyhow!("recording state poisoned"))?; + record.push(RecordedFrame { + timing: FrameTiming { + pts_ms: None, + dts_ms: frame.dts_ms(), + }, frame: frame.clone(), }); - } } } diff --git a/src/serial_core/utils.rs b/src/serial_core/utils.rs index f5b2542..15eff52 100644 --- a/src/serial_core/utils.rs +++ b/src/serial_core/utils.rs @@ -1,6 +1,9 @@ - use std::time::Instant; +use tokio_serial::available_ports; + +use crate::serial_core::error::SerialError; + pub fn usize_to_u16_be_bytes(n: usize) -> [u8; 2] { (n as u16).to_be_bytes() } @@ -33,6 +36,16 @@ pub fn elapsed_millis(start_at: Instant) -> u64 { start_at.elapsed().as_millis() as u64 } +pub fn serial_enum() -> Result, SerialError> { + let ports = available_ports() + .map_err(|_| SerialError::ScanError)? + .into_iter() + .map(|info| info.port_name) + .collect(); + + Ok(ports) +} + #[cfg(test)] mod test { use anyhow::Ok; @@ -41,7 +54,9 @@ mod test { #[test] fn test_crc8_itu() -> anyhow::Result<()> { - let req_vec = vec![0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00]; + let req_vec = vec![ + 0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00, + ]; let checksum = calc_crc8_itu(req_vec.as_slice()); assert_eq!(checksum, 0x7A); @@ -50,10 +65,12 @@ mod test { #[test] fn test_crc8_smbus() -> anyhow::Result<()> { - let req_vec = vec![0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00]; + let req_vec = vec![ + 0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00, + ]; let checksum = calc_crc8_smbus(req_vec.as_slice()); assert_eq!(checksum, 0x2F); Ok(()) } -} \ No newline at end of file +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..d55c249 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,706 @@ +use std::{fs, sync::Arc, time::Duration}; + +use anyhow::{Result, anyhow}; +use crossterm::event::{self, Event}; +use ratatui::{ + DefaultTerminal, Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::Line, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use ratatui_textarea::{CursorMove, DataCursor, Input, Key, TextArea}; + +use crate::{ + app::{SerialConnectionState, shutdown_all_sessions}, + cmd, + serial_core::utils::serial_enum, +}; + +const APP_POLL_INTERVAL_MS: u64 = 250; +const MAX_COMMAND_LINES: usize = 512; +const COMMAND_INPUT_TITLE: &str = "Command Input [Enter=run Tab=complete]"; +const COMPLETION_PREVIEW_LIMIT: usize = 4; +const COMMAND_COMPLETIONS: &[&str] = &[ + "/help", "/scan", "/status", "/open", "/close", "/export", "/set", "/echo", "/exit", "/quit", +]; +const SETTING_COMPLETIONS: &[&str] = &["export"]; + +pub async fn run() -> Result<()> { + let serial_state = Arc::new(SerialConnectionState::new()); + let mut terminal = ratatui::init(); + let run_result = { + let mut app = TuiApp::new(Arc::clone(&serial_state)); + app.run(&mut terminal).await + }; + let shutdown_result = shutdown_all_sessions(&serial_state).await; + ratatui::restore(); + + run_result?; + shutdown_result.map_err(|err| anyhow!("failed to close active serial sessions: {err}"))?; + Ok(()) +} + +struct TuiApp { + should_quit: bool, + command_output: Vec, + command_input: TextArea<'static>, + serial_state: Arc, + completion_hint: Option, + completion_cycle: Option, +} + +impl TuiApp { + fn new(serial_state: Arc) -> Self { + let mut app = Self { + should_quit: false, + command_output: Vec::new(), + command_input: new_command_input(), + serial_state, + completion_hint: None, + completion_cycle: None, + }; + + app.push_command_lines([ + "JE-Skin CLI TUI".to_string(), + "Streaming serial text has been disabled to keep the terminal responsive.".to_string(), + "Use /scan, /open , /status, /export , /set export

, /close , /close, /exit.".to_string(), + "The right pane now shows active collectors only.".to_string(), + "Press Tab to autocomplete commands and paths.".to_string(), + ]); + app.refresh_input_block(); + + app + } + + async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { + while !self.should_quit { + terminal.draw(|frame| self.draw(frame))?; + + if event::poll(Duration::from_millis(APP_POLL_INTERVAL_MS))? { + match event::read()? { + Event::Key(key_event) => self.handle_key(Input::from(key_event)).await?, + Event::Resize(_, _) => {} + _ => {} + } + } + } + + Ok(()) + } + + async fn handle_key(&mut self, input: Input) -> Result<()> { + match input { + Input { key: Key::Esc, .. } + | Input { + key: Key::Char('c'), + ctrl: true, + .. + } => { + self.should_quit = true; + } + Input { + key: Key::Enter, .. + } => { + self.invalidate_completion(); + self.submit_command().await?; + } + Input { key: Key::Tab, .. } => { + self.complete_input(); + } + Input { key: Key::Null, .. } => {} + other => { + self.invalidate_completion(); + self.command_input.input(other); + } + } + + Ok(()) + } + + async fn submit_command(&mut self) -> Result<()> { + let raw_command = self.command_input.lines().join("\n"); + let command = raw_command.trim().to_string(); + self.command_input = new_command_input(); + + if command.is_empty() { + return Ok(()); + } + + self.push_command_line(format!("> {command}")); + let response = cmd::execute_input(&command, Arc::clone(&self.serial_state)).await; + self.push_command_lines(response.lines); + self.should_quit |= response.should_exit; + + Ok(()) + } + + fn complete_input(&mut self) { + if self.apply_cycle_completion() { + return; + } + + let line = self.current_input_line(); + let DataCursor(_, cursor_col) = self.command_input.cursor(); + let Some(request) = build_completion_request(&line, cursor_col) else { + self.set_completion_hint(Some("No completion available".to_string())); + return; + }; + + let candidates = self.completion_candidates(&request); + if candidates.is_empty() { + self.set_completion_hint(Some("No completion matches".to_string())); + return; + } + + if candidates.len() == 1 { + let replacement = finalize_unique_completion(&request, &candidates[0], &line); + self.replace_input_range(request.start, request.end, &replacement); + self.invalidate_completion(); + return; + } + + let common_prefix = longest_common_prefix(&candidates); + let common_prefix_len = common_prefix.chars().count(); + let token_len = request.token.chars().count(); + let preview = completion_preview(&candidates); + + if common_prefix_len > token_len { + self.replace_input_range(request.start, request.end, &common_prefix); + self.completion_cycle = Some(CompletionCycle::new( + request.start, + request.start + common_prefix_len, + candidates, + )); + self.set_completion_hint(Some(format!("Matches: {preview}"))); + return; + } + + self.completion_cycle = Some(CompletionCycle::new(request.start, request.end, candidates)); + self.set_completion_hint(Some(format!("Tab again to cycle: {preview}"))); + } + + fn apply_cycle_completion(&mut self) -> bool { + let Some(mut cycle) = self.completion_cycle.take() else { + return false; + }; + + if cycle.candidates.is_empty() { + return false; + } + + let candidate_index = cycle.next_index % cycle.candidates.len(); + let candidate = cycle.candidates[candidate_index].clone(); + let new_end = cycle.start + candidate.chars().count(); + self.replace_input_range(cycle.start, cycle.end, &candidate); + cycle.end = new_end; + cycle.next_index = candidate_index + 1; + let preview = completion_preview(&cycle.candidates); + self.completion_cycle = Some(cycle); + self.set_completion_hint(Some(format!("Cycling: {preview}"))); + true + } + + fn completion_candidates(&self, request: &CompletionRequest) -> Vec { + let mut candidates = match &request.kind { + CompletionKind::Command => command_completion_candidates(&request.token), + CompletionKind::Setting => setting_completion_candidates(&request.token), + CompletionKind::SerialPort { command_name } => { + serial_port_completion_candidates(command_name, &request.token, &self.serial_state) + } + CompletionKind::FileSystemPath => filesystem_path_candidates(&request.token), + }; + + candidates.sort(); + candidates.dedup(); + candidates + } + + fn push_command_line(&mut self, line: impl Into) { + self.command_output.push(line.into()); + trim_lines(&mut self.command_output, MAX_COMMAND_LINES); + } + + fn push_command_lines(&mut self, lines: I) + where + I: IntoIterator, + { + for line in lines { + self.push_command_line(line); + } + } + + fn draw(&self, frame: &mut Frame) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(frame.area()); + + let left = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(6), Constraint::Length(3)]) + .split(columns[0]); + + let left_title = Line::styled( + "Command Console", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + let left_output = Paragraph::new(render_lines( + &self.command_output, + "No command output yet. Type /help.", + )) + .block(Block::default().borders(Borders::ALL).title(left_title)) + .wrap(Wrap { trim: false }) + .scroll((scroll_offset(left[0], self.command_output.len()), 0)); + frame.render_widget(left_output, left[0]); + + frame.render_widget(&self.command_input, left[1]); + + let collector_lines = self + .serial_state + .collector_lines() + .unwrap_or_else(|err| vec![format!("Read collector status failed: {err}")]); + let collector_count = self + .serial_state + .active_ports() + .map(|ports| ports.len()) + .unwrap_or(0); + let right_title = Line::styled( + format!("Collectors [{collector_count}]"), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ); + let right_output = Paragraph::new(render_lines( + &collector_lines, + "No active serial collectors.", + )) + .block(Block::default().borders(Borders::ALL).title(right_title)) + .wrap(Wrap { trim: false }) + .scroll((scroll_offset(columns[1], collector_lines.len()), 0)); + frame.render_widget(right_output, columns[1]); + } + + fn invalidate_completion(&mut self) { + self.completion_hint = None; + self.completion_cycle = None; + self.refresh_input_block(); + } + + fn set_completion_hint(&mut self, hint: Option) { + self.completion_hint = hint; + self.refresh_input_block(); + } + + fn refresh_input_block(&mut self) { + let title = match self.completion_hint.as_deref() { + Some(hint) => format!("{COMMAND_INPUT_TITLE} | {hint}"), + None => COMMAND_INPUT_TITLE.to_string(), + }; + self.command_input + .set_block(Block::default().borders(Borders::ALL).title(title)); + } + + fn current_input_line(&self) -> String { + self.command_input + .lines() + .first() + .cloned() + .unwrap_or_default() + } + + fn replace_input_range(&mut self, start: usize, end: usize, replacement: &str) { + let line = self.current_input_line(); + let start_byte = char_to_byte_index(&line, start); + let end_byte = char_to_byte_index(&line, end); + let new_line = format!( + "{}{}{}", + &line[..start_byte], + replacement, + &line[end_byte..] + ); + let cursor_col = start + replacement.chars().count(); + + self.command_input.clear(); + self.command_input.insert_str(&new_line); + self.command_input.move_cursor(CursorMove::Jump( + 0, + cursor_col.min(u16::MAX as usize) as u16, + )); + self.refresh_input_block(); + } +} + +fn new_command_input() -> TextArea<'static> { + let mut input = TextArea::default(); + input.set_cursor_line_style(Style::default()); + input.set_style(Style::default().fg(Color::White)); + input.set_placeholder_text( + "/scan | /open /dev/ttyUSB0 | /export /dev/ttyUSB0 | /set export ./exports", + ); + input +} + +#[derive(Debug, Clone)] +struct CompletionCycle { + start: usize, + end: usize, + candidates: Vec, + next_index: usize, +} + +impl CompletionCycle { + fn new(start: usize, end: usize, candidates: Vec) -> Self { + Self { + start, + end, + candidates, + next_index: 0, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CompletionKind { + Command, + Setting, + SerialPort { command_name: String }, + FileSystemPath, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CompletionRequest { + kind: CompletionKind, + start: usize, + end: usize, + token: String, +} + +fn build_completion_request(line: &str, cursor_col: usize) -> Option { + if !line.starts_with('/') { + return None; + } + + let char_len = line.chars().count(); + let cursor_col = cursor_col.min(char_len); + let (start, end) = token_bounds(line, cursor_col); + let token = slice_by_chars(line, start, end).to_string(); + + if start == 0 { + return Some(CompletionRequest { + kind: CompletionKind::Command, + start, + end, + token, + }); + } + + let command_name = line.split_whitespace().next()?.to_string(); + let token_prefix = &line[..char_to_byte_index(line, start)]; + let token_position = token_prefix.split_whitespace().count(); + + if command_name == "/set" { + if token_position == 1 { + return Some(CompletionRequest { + kind: CompletionKind::Setting, + start, + end, + token, + }); + } + + let mut parts = line.split_whitespace(); + let _ = parts.next(); + let setting_name = parts.next().unwrap_or_default(); + if setting_name == "export" { + return Some(CompletionRequest { + kind: CompletionKind::FileSystemPath, + start, + end, + token, + }); + } + } + + if matches!(command_name.as_str(), "/open" | "/close" | "/export") { + return Some(CompletionRequest { + kind: CompletionKind::SerialPort { command_name }, + start, + end, + token, + }); + } + + None +} + +fn command_completion_candidates(prefix: &str) -> Vec { + COMMAND_COMPLETIONS + .iter() + .filter(|command| command.starts_with(prefix)) + .map(|command| (*command).to_string()) + .collect() +} + +fn setting_completion_candidates(prefix: &str) -> Vec { + SETTING_COMPLETIONS + .iter() + .filter(|setting| setting.starts_with(prefix)) + .map(|setting| (*setting).to_string()) + .collect() +} + +fn serial_port_completion_candidates( + command_name: &str, + prefix: &str, + state: &SerialConnectionState, +) -> Vec { + match command_name { + "/open" => { + let mut candidates = serial_enum() + .unwrap_or_default() + .into_iter() + .filter(|port| port.starts_with(prefix)) + .collect::>(); + candidates.extend(filesystem_path_candidates(prefix)); + candidates + } + "/close" => state + .active_ports() + .unwrap_or_default() + .into_iter() + .filter(|port| port.starts_with(prefix)) + .collect(), + "/export" => state + .exportable_ports() + .unwrap_or_default() + .into_iter() + .filter(|port| port.starts_with(prefix)) + .collect(), + _ => Vec::new(), + } +} + +fn filesystem_path_candidates(prefix: &str) -> Vec { + if prefix.is_empty() { + return Vec::new(); + } + + let (search_dir, dir_prefix, name_prefix, separator) = filesystem_search_parts(prefix); + let entries = match fs::read_dir(&search_dir) { + Ok(entries) => entries, + Err(_) => return Vec::new(), + }; + + let mut candidates = Vec::new(); + for entry in entries.flatten() { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if !file_name.starts_with(name_prefix) { + continue; + } + + let mut candidate = format!("{dir_prefix}{file_name}"); + if entry.path().is_dir() { + candidate.push(separator); + } + candidates.push(candidate); + } + + candidates.sort(); + candidates +} + +fn filesystem_search_parts(prefix: &str) -> (String, String, &str, char) { + let separator = if prefix.contains('\\') && !prefix.contains('/') { + '\\' + } else { + '/' + }; + + match prefix.rfind(['/', '\\']) { + Some(index) => { + let dir_prefix = &prefix[..=index]; + let search_dir = if dir_prefix.is_empty() { + ".".to_string() + } else { + dir_prefix.to_string() + }; + let name_prefix = &prefix[index + 1..]; + (search_dir, dir_prefix.to_string(), name_prefix, separator) + } + None => (".".to_string(), String::new(), prefix, separator), + } +} + +fn finalize_unique_completion(request: &CompletionRequest, candidate: &str, line: &str) -> String { + match &request.kind { + CompletionKind::Command => { + if command_takes_argument(candidate) && request.end == line.chars().count() { + format!("{candidate} ") + } else { + candidate.to_string() + } + } + CompletionKind::Setting => { + if request.end == line.chars().count() { + format!("{candidate} ") + } else { + candidate.to_string() + } + } + CompletionKind::SerialPort { .. } => candidate.to_string(), + CompletionKind::FileSystemPath => { + if candidate.ends_with('/') || request.end < line.chars().count() { + candidate.to_string() + } else { + format!("{candidate} ") + } + } + } +} + +fn command_takes_argument(command: &str) -> bool { + matches!(command, "/open" | "/close" | "/export" | "/set" | "/echo") +} + +fn completion_preview(candidates: &[String]) -> String { + let preview = candidates + .iter() + .take(COMPLETION_PREVIEW_LIMIT) + .cloned() + .collect::>() + .join(", "); + if candidates.len() > COMPLETION_PREVIEW_LIMIT { + format!("{preview}, ...") + } else { + preview + } +} + +fn longest_common_prefix(items: &[String]) -> String { + let Some(first) = items.first() else { + return String::new(); + }; + + let mut prefix = first.clone(); + while !prefix.is_empty() { + if items.iter().all(|item| item.starts_with(&prefix)) { + return prefix; + } + prefix.pop(); + } + + String::new() +} + +fn token_bounds(line: &str, cursor_col: usize) -> (usize, usize) { + let chars = line.chars().collect::>(); + let char_len = chars.len(); + let cursor = cursor_col.min(char_len); + + let mut start = cursor; + while start > 0 && !chars[start - 1].is_whitespace() { + start -= 1; + } + + let mut end = cursor; + while end < char_len && !chars[end].is_whitespace() { + end += 1; + } + + (start, end) +} + +fn slice_by_chars(text: &str, start: usize, end: usize) -> &str { + let start_byte = char_to_byte_index(text, start); + let end_byte = char_to_byte_index(text, end); + &text[start_byte..end_byte] +} + +fn char_to_byte_index(text: &str, char_index: usize) -> usize { + text.char_indices() + .nth(char_index) + .map(|(byte_index, _)| byte_index) + .unwrap_or(text.len()) +} + +fn trim_lines(lines: &mut Vec, max_lines: usize) { + if lines.len() > max_lines { + let overflow = lines.len() - max_lines; + lines.drain(0..overflow); + } +} + +fn render_lines(lines: &[String], empty_hint: &str) -> String { + if lines.is_empty() { + empty_hint.to_string() + } else { + lines.join("\n") + } +} + +fn scroll_offset(area: Rect, line_count: usize) -> u16 { + let visible_lines = area.height.saturating_sub(2) as usize; + line_count.saturating_sub(visible_lines) as u16 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_command_completion_request() { + let request = build_completion_request("/op", 3).unwrap(); + assert_eq!(request.kind, CompletionKind::Command); + assert_eq!(request.token, "/op"); + assert_eq!((request.start, request.end), (0, 3)); + } + + #[test] + fn build_open_path_completion_request() { + let line = "/open /dev/ttyU"; + let request = build_completion_request(line, line.chars().count()).unwrap(); + assert_eq!( + request.kind, + CompletionKind::SerialPort { + command_name: "/open".to_string() + } + ); + assert_eq!(request.token, "/dev/ttyU"); + } + + #[test] + fn build_setting_completion_request() { + let line = "/set exp"; + let request = build_completion_request(line, line.chars().count()).unwrap(); + assert_eq!(request.kind, CompletionKind::Setting); + assert_eq!(request.token, "exp"); + } + + #[test] + fn build_set_export_path_request() { + let line = "/set export ./out"; + let request = build_completion_request(line, line.chars().count()).unwrap(); + assert_eq!(request.kind, CompletionKind::FileSystemPath); + assert_eq!(request.token, "./out"); + } + + #[test] + fn longest_common_prefix_for_paths() { + let prefix = longest_common_prefix(&[ + "/dev/ttyUSB0".to_string(), + "/dev/ttyUSB1".to_string(), + "/dev/ttyUSB2".to_string(), + ]); + assert_eq!(prefix, "/dev/ttyUSB"); + } + + #[test] + fn command_completion_candidates_match_prefix() { + let candidates = command_completion_candidates("/st"); + assert_eq!(candidates, vec!["/status".to_string()]); + } +}