Add TUI serial collector and export commands
This commit is contained in:
284
src/app.rs
284
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<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(¤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<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()
|
||||
}
|
||||
}
|
||||
|
||||
355
src/cmd.rs
355
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<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)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
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<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())
|
||||
}
|
||||
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<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());
|
||||
}
|
||||
}
|
||||
|
||||
53
src/flog.rs
53
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();
|
||||
}
|
||||
.level(log::LevelFilter::Debug)
|
||||
.chain(console_config)
|
||||
.chain(data_based_config)
|
||||
.apply()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::serial_core::{frame::TestFrame, record::Recording};
|
||||
|
||||
pub mod test;
|
||||
pub mod tactile_a;
|
||||
pub type TestRecording = Recording<TestFrame>;
|
||||
pub mod test;
|
||||
pub type TestRecording = Recording<TestFrame>;
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
@@ -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<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>>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,4 +42,3 @@ pub fn serial_enum() -> Result<Vec<String>, SerialError> {
|
||||
|
||||
Ok(ports)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
706
src/tui.rs
Normal file
706
src/tui.rs
Normal 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()]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user