Add TUI serial collector and export commands

This commit is contained in:
lenn
2026-04-24 02:58:44 +08:00
parent 8182e67152
commit 6e639313e8
14 changed files with 1330 additions and 248 deletions

View File

@@ -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<Option<Arc<SerialSession>>>,
last_record: Mutex<Option<Arc<Mutex<TactileARecording>>>>,
sessions: Mutex<HashMap<String, Arc<SerialSession>>>,
last_records: Mutex<HashMap<String, Arc<Mutex<TactileARecording>>>>,
export_dir: Mutex<PathBuf>,
}
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<Option<String>, 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<Vec<String>, SerialError> {
let sessions = self.sessions.lock().map_err(|_| SerialError::StateError)?;
let mut ports = sessions.keys().cloned().collect::<Vec<_>>();
ports.sort();
Ok(ports)
}
pub fn current_record(&self) -> Result<Option<Arc<Mutex<TactileARecording>>>, 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<Vec<String>, SerialError> {
let ports = self.active_ports()?;
if ports.is_empty() {
return Ok(vec![
"No active serial collectors.".to_string(),
"Use /open <port> to start collecting.".to_string(),
]);
}
Ok(ports
.into_iter()
.map(|port| format!("Serial {port} is collecting..."))
.collect())
}
pub fn exportable_ports(&self) -> Result<Vec<String>, 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<PathBuf, SerialError> {
let export_dir = self
.export_dir
.lock()
.map_err(|_| SerialError::StateError)?;
Ok(export_dir.clone())
}
pub fn set_export_dir(&self, path: &str) -> Result<PathBuf, SerialError> {
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<PathBuf> {
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(&current_record),
cancel.clone(),
));
{
let mut active_session = state.session.lock().map_err(|_| SerialError::StateError)?;
*active_session = Some(Arc::clone(&session));
let mut sessions = state.sessions.lock().map_err(|_| SerialError::StateError)?;
if sessions.contains_key(&port_name) {
return Err(SerialError::AlreadyConnected);
}
{
let mut last_record = state
.last_record
sessions.insert(port_name.clone(), Arc::clone(&session));
}
state
.last_records
.lock()
.map_err(|_| SerialError::StateError)?;
*last_record = None;
}
.map_err(|_| SerialError::StateError)?
.remove(&port_name);
let task_state = Arc::clone(&state);
let task_session = Arc::clone(&session);
let task_record = Arc::clone(&current_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<SerialConnectionState>) -> 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<Vec<String>, SerialError> {
let sessions = {
let mut sessions = state.sessions.lock().map_err(|_| SerialError::StateError)?;
let drained = sessions
.drain()
.map(|(_, session)| session)
.collect::<Vec<_>>();
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<SerialSession>,
) -> 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<PathBuf, SerialError> {
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::<String>();
let trimmed = sanitized.trim_matches('_');
if trimmed.is_empty() {
"serial".to_string()
} else {
trimmed.to_string()
}
}

View File

@@ -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<String>,
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<String>),
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<String>,
pub should_exit: bool,
}
impl CommandResponse {
fn new(lines: Vec<String>, 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<String>) -> Self {
Self::new(vec![line.into()], false)
}
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
}
pub fn parse_command(input: &str) -> Result<Command> {
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())
}
_ => bail!("unknown setting: {setting_name}"),
}
}
other => Command::Unknown(other.to_string()),
};
Ok(command)
}
pub async fn execute_input(input: &str, state: Arc<SerialConnectionState>) -> 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<SerialConnectionState>,
) -> 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())
}
_ => {
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,
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}")),
},
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,
});
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<Terminal<CrosstermBackend<Stdout>>> {
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<CrosstermBackend<Stdout>>) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn run_app(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> 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<String> {
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 <path> Start collecting on a serial port".to_string(),
" /close <path> Stop collecting on one serial port".to_string(),
" /close Stop collecting on all serial ports".to_string(),
" /export <port> Export one serial recording to CSV".to_string(),
" /set export <dir> Set the export directory".to_string(),
" /echo <text> 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());
}
}

View File

@@ -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()
@@ -18,8 +21,7 @@ pub fn setup_logger() {
let console_config = fern::Dispatch::new()
.format(move |out, message, record| {
out.finish(
format_args!(
out.finish(format_args!(
"{colors_line}[{data} {level} {target} {colors_line}] {message}\x1B[0m",
colors_line = format_args!(
"\x1B[{}m",
@@ -29,23 +31,20 @@ pub fn setup_logger() {
target = record.target(),
level = colors_level.color(record.level()),
message = message,
)
);
));
})
.level(level)
.chain(std::io::stdout());
let data_based_config = fern::Dispatch::new()
.format(move |out, message, record| {
out.finish(
format_args!(
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"));

View File

@@ -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
}

View File

@@ -1,5 +1,5 @@
use crate::serial_core::{frame::TestFrame, record::Recording};
pub mod test;
pub mod tactile_a;
pub mod test;
pub type TestRecording = Recording<TestFrame>;

View File

@@ -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::<Vec<i32>>();
@@ -223,14 +219,13 @@ impl Codec<TactileAFrame> 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<TactileARepFrame> for TactileACsvExporter {
header
}
fn csv_row(
&self,
item: &RecordedFrame<TactileARepFrame>,
) -> anyhow::Result<Vec<String>> {
fn csv_row(&self, item: &RecordedFrame<TactileARepFrame>) -> anyhow::Result<Vec<String>> {
let packet = TactileADataPacket::try_from(&item.frame)?;
let summary: i32 = packet.data.iter().sum();
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
@@ -293,13 +285,12 @@ impl CsvExporter<TactileAFrame> for TactileACsvExporter {
header
}
fn csv_row(
&self,
item: &RecordedFrame<TactileAFrame>,
) -> anyhow::Result<Vec<String>> {
fn csv_row(&self, item: &RecordedFrame<TactileAFrame>) -> anyhow::Result<Vec<String>> {
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::<i32>()?);
}
@@ -364,7 +357,10 @@ impl CsvImporter<TactileADataPacket> for TactileACsvImporter {
}
}
pub fn export_recording_csv<W>(recording: &Recording<TactileAFrame>, writer: W) -> anyhow::Result<()>
pub fn export_recording_csv<W>(
recording: &Recording<TactileAFrame>,
writer: W,
) -> anyhow::Result<()>
where
W: std::io::Write,
{

View File

@@ -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<u8>,
}
@@ -23,7 +20,11 @@ impl TestCodec {
}
impl Codec<TestFrame> for TestCodec {
fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result<Vec<TestFrame>, CodecError> {
fn decode(
&mut self,
input: &[u8],
session_started_at: Instant,
) -> Result<Vec<TestFrame>, 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<i32>,
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<TestDataPacket, Self::Error> {
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<TestFrame> for TestDataPacket {
@@ -145,14 +149,17 @@ impl TryFrom<&TestFrame> for TestDataPacket {
// }
// }
impl CsvExporter<TestFrame> for TestCsvExporter {
type Error = CodecError;
fn csv_header(&self, recording: &Recording<TestFrame>) -> Vec<String> {
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<String> = Vec::new();
for i in 0..channel_nb {
@@ -180,7 +187,7 @@ impl TestCsvImporter {
}
}
fn parse_record(&mut self, record: StringRecord) -> anyhow::Result<TestDataPacket>{
fn parse_record(&mut self, record: StringRecord) -> anyhow::Result<TestDataPacket> {
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::<i32>()?);
}
@@ -226,7 +235,6 @@ impl CsvImporter<TestDataPacket> for TestCsvImporter {
}
}
pub fn export_recording_csv<W>(recording: &Recording<TestFrame>, 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(())
}

View File

@@ -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,

View File

@@ -7,7 +7,7 @@ pub struct TestFrame {
pub length: usize,
pub payload: Vec<u8>,
pub checksum: u8,
pub dts_ms: u64
pub dts_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -35,23 +35,22 @@ pub struct TactileARepFrame {
pub meta: TactileAFrameMetaData,
pub status: TactileAFrameStatusCode,
pub payload: Vec<u8>,
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<F, T>: Send {
async fn on_frame(&mut self, frame: &F) -> Result<Option<Vec<T>>>;
}

View File

@@ -42,4 +42,3 @@ pub fn serial_enum() -> Result<Vec<String>, SerialError> {
Ok(ports)
}

View File

@@ -7,16 +7,18 @@ pub struct FrameTiming {
#[derive(Clone)]
pub struct RecordedFrame<F> {
pub timing: FrameTiming,
pub frame: F
pub frame: F,
}
#[derive(Clone, Default)]
pub struct Recording<F> {
pub frames: Vec<RecordedFrame<F>>
pub frames: Vec<RecordedFrame<F>>,
}
impl<F> Recording<F> {
pub fn new() -> Recording<F> { Self { frames: Vec::new() } }
pub fn new() -> Recording<F> {
Self { frames: Vec::new() }
}
pub fn push(&mut self, ite: RecordedFrame<F>) {
self.frames.push(ite);
}
@@ -33,11 +35,7 @@ pub trait CsvImporter<P> {
fn load<R: std::io::Read>(&mut self, reader: R) -> anyhow::Result<Vec<P>>;
}
pub fn write_csv<F, E, W>(
recording: &Recording<F>,
exporter: &E,
writer: W,
) -> anyhow::Result<()>
pub fn write_csv<F, E, W>(recording: &Recording<F>, exporter: &E, writer: W) -> anyhow::Result<()>
where
E: CsvExporter<F>,
W: std::io::Write,

View File

@@ -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::<Vec<i32>>());
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(),
});
}
}
}

View File

@@ -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<Vec<String>, 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,7 +65,9 @@ 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);

706
src/tui.rs Normal file
View File

@@ -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<String>,
command_input: TextArea<'static>,
serial_state: Arc<SerialConnectionState>,
completion_hint: Option<String>,
completion_cycle: Option<CompletionCycle>,
}
impl TuiApp {
fn new(serial_state: Arc<SerialConnectionState>) -> 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 <port>, /status, /export <port>, /set export <dir>, /close <port>, /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<String> {
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<String>) {
self.command_output.push(line.into());
trim_lines(&mut self.command_output, MAX_COMMAND_LINES);
}
fn push_command_lines<I>(&mut self, lines: I)
where
I: IntoIterator<Item = String>,
{
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<String>) {
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<String>,
next_index: usize,
}
impl CompletionCycle {
fn new(start: usize, end: usize, candidates: Vec<String>) -> 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<CompletionRequest> {
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<String> {
COMMAND_COMPLETIONS
.iter()
.filter(|command| command.starts_with(prefix))
.map(|command| (*command).to_string())
.collect()
}
fn setting_completion_candidates(prefix: &str) -> Vec<String> {
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<String> {
match command_name {
"/open" => {
let mut candidates = serial_enum()
.unwrap_or_default()
.into_iter()
.filter(|port| port.starts_with(prefix))
.collect::<Vec<_>>();
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<String> {
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::<Vec<_>>()
.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::<Vec<_>>();
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<String>, 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()]);
}
}