From 7688986ad752ebc1a8c43081cc40848253b2cb06 Mon Sep 17 00:00:00 2001 From: lennlouisgeek Date: Fri, 3 Apr 2026 00:47:36 +0800 Subject: [PATCH] exchange tast to tactilea --- src-tauri/program.log2026-04-03 | 0 src-tauri/src/commands/file_explorer.rs | 208 +++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/serial.rs | 229 ++++- src-tauri/src/lib.rs | 4 + src-tauri/src/serial_core/codecs/tactile_a.rs | 114 ++- src-tauri/src/serial_core/codecs/test.rs | 5 +- src-tauri/src/serial_core/mod.rs | 6 +- src-tauri/src/serial_core/record.rs | 16 +- src-tauri/src/serial_core/serial.rs | 20 +- src/lib/components/FileExplorerModal.svelte | 833 ++++++++++++++++++ src/lib/components/HudPanel.svelte | 96 +- .../components/PressureMatrixViewer.svelte | 8 +- src/lib/types/hud.ts | 39 + src/routes/+page.svelte | 410 ++++++++- 15 files changed, 1842 insertions(+), 147 deletions(-) create mode 100644 src-tauri/program.log2026-04-03 create mode 100644 src-tauri/src/commands/file_explorer.rs create mode 100644 src/lib/components/FileExplorerModal.svelte diff --git a/src-tauri/program.log2026-04-03 b/src-tauri/program.log2026-04-03 new file mode 100644 index 0000000..e69de29 diff --git a/src-tauri/src/commands/file_explorer.rs b/src-tauri/src/commands/file_explorer.rs new file mode 100644 index 0000000..b537d72 --- /dev/null +++ b/src-tauri/src/commands/file_explorer.rs @@ -0,0 +1,208 @@ +use serde::Serialize; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; +use tauri::{AppHandle, Manager}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileExplorerRoot { + pub label: String, + pub path: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileExplorerEntry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub size_bytes: Option, + pub modified_ms: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileExplorerListResponse { + pub current_path: String, + pub parent_path: Option, + pub roots: Vec, + pub entries: Vec, +} + +#[tauri::command] +pub fn file_explorer_list( + app: AppHandle, + path: Option, + extensions: Option>, +) -> Result { + let current_path = resolve_start_path(&app, path)?; + let extension_filter = normalize_extensions(extensions); + + let mut entries = fs::read_dir(¤t_path) + .map_err(|err| format!("Failed to read '{}': {err}", current_path.display()))? + .filter_map(Result::ok) + .filter_map(|entry| { + let file_type = entry.file_type().ok()?; + let metadata = entry.metadata().ok(); + let is_dir = file_type.is_dir(); + let path = entry.path(); + + if !is_dir && !extension_filter.is_empty() { + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .unwrap_or_default(); + if !extension_filter.contains(&extension) { + return None; + } + } + + let name = entry.file_name().to_string_lossy().to_string(); + let size_bytes = if is_dir { + None + } else { + metadata.as_ref().map(|value| value.len()) + }; + let modified_ms = metadata + .as_ref() + .and_then(|value| value.modified().ok()) + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|value| value.as_millis()); + + Some(FileExplorerEntry { + name, + path: path.display().to_string(), + is_dir, + size_bytes, + modified_ms, + }) + }) + .collect::>(); + + entries.sort_by(|left, right| { + if left.is_dir != right.is_dir { + return right.is_dir.cmp(&left.is_dir); + } + + left.name + .to_ascii_lowercase() + .cmp(&right.name.to_ascii_lowercase()) + }); + + Ok(FileExplorerListResponse { + current_path: current_path.display().to_string(), + parent_path: current_path.parent().map(|parent| parent.display().to_string()), + roots: collect_roots(&app), + entries, + }) +} + +fn normalize_extensions(extensions: Option>) -> HashSet { + extensions + .unwrap_or_default() + .into_iter() + .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect() +} + +fn resolve_start_path(app: &AppHandle, raw_path: Option) -> Result { + if let Some(value) = raw_path { + let trimmed = value.trim(); + if trimmed.is_empty() { + return resolve_default_path(app); + } + + let mut candidate = PathBuf::from(trimmed); + if candidate.is_relative() { + candidate = std::env::current_dir() + .map_err(|err| format!("Failed to read current dir: {err}"))? + .join(candidate); + } + + if !candidate.exists() { + return Err(format!("Path does not exist: {}", candidate.display())); + } + + if candidate.is_file() { + return candidate + .parent() + .map(|parent| parent.to_path_buf()) + .ok_or_else(|| format!("No parent directory for {}", candidate.display())); + } + + return Ok(candidate); + } + + resolve_default_path(app) +} + +fn resolve_default_path(app: &AppHandle) -> Result { + if let Ok(path) = app.path().desktop_dir() { + return Ok(path); + } + if let Ok(path) = app.path().document_dir() { + return Ok(path); + } + if let Ok(path) = app.path().download_dir() { + return Ok(path); + } + if let Ok(path) = app.path().home_dir() { + return Ok(path); + } + + std::env::current_dir().map_err(|err| format!("Failed to resolve default path: {err}")) +} + +fn collect_roots(app: &AppHandle) -> Vec { + let mut roots = Vec::new(); + let mut seen = HashSet::new(); + + let mut push_root = |label: &str, path: PathBuf| { + let normalized = path.display().to_string(); + if normalized.is_empty() || !Path::new(&normalized).exists() { + return; + } + + if seen.insert(normalized.clone()) { + roots.push(FileExplorerRoot { + label: label.to_string(), + path: normalized, + }); + } + }; + + if let Ok(path) = app.path().desktop_dir() { + push_root("Desktop", path); + } + if let Ok(path) = app.path().document_dir() { + push_root("Documents", path); + } + if let Ok(path) = app.path().download_dir() { + push_root("Downloads", path); + } + if let Ok(path) = app.path().home_dir() { + push_root("Home", path); + } + + #[cfg(target_os = "windows")] + { + for letter in b'A'..=b'Z' { + let drive = format!("{}:\\", letter as char); + let drive_path = PathBuf::from(&drive); + if drive_path.exists() { + push_root(&format!("{}:", letter as char), drive_path); + } + } + } + + #[cfg(not(target_os = "windows"))] + { + push_root("Root", PathBuf::from("/")); + } + + roots +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c0fc4f2..b5e49fa 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,2 +1,3 @@ +pub mod file_explorer; pub mod serial; pub mod window; diff --git a/src-tauri/src/commands/serial.rs b/src-tauri/src/commands/serial.rs index d3d3855..85b5836 100644 --- a/src-tauri/src/commands/serial.rs +++ b/src-tauri/src/commands/serial.rs @@ -1,19 +1,27 @@ -use crate::serial_core::codecs::test::{export_recording_csv, TestCodec, TestCsvImporter, TestHandler}; +use crate::serial_core::codecs::tactile_a::{ + export_recording_csv, TactileACodec, TactileACsvImporter, TactileAHandler, +}; use crate::serial_core::error::SerialError; use crate::serial_core::record::CsvImporter; -use crate::serial_core::serial::PollMode; -use crate::serial_core::{TestRecording, serial}; +use crate::serial_core::serial::{PollMode, TactileAPollRequester}; +use crate::serial_core::{serial, TactileARecording}; use log::info; use serde::Serialize; use std::fs::File; use std::io::Cursor; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::{Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{async_runtime::JoinHandle, AppHandle, Manager, State}; use tokio_serial::{available_ports, SerialPortBuilderExt}; use tokio_util::sync::CancellationToken; -type SharedTestRecording = Arc>; +const DEFAULT_TACTILE_COLS: usize = 7; +const DEFAULT_TACTILE_ROWS: usize = 12; +const DEFAULT_TACTILE_POLL_INTERVAL_MS: u64 = 10; +const DEFAULT_TACTILE_REPLY_TIMEOUT_MS: u64 = 140; + +type SharedTactileRecording = Arc>; #[derive(Serialize)] @@ -49,17 +57,24 @@ pub struct SerialImportResponse { pub message: String, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SerialRecordStateResponse { + pub has_data: bool, + pub frame_count: usize, +} + struct SerialSession { port: String, cancel: CancellationToken, task: JoinHandle<()>, - current_record: SharedTestRecording, + current_record: SharedTactileRecording, } #[derive(Default)] pub struct SerialConnectionState { session: Mutex>, - last_record: Mutex> + last_record: Mutex> } #[tauri::command] @@ -92,7 +107,7 @@ pub async fn serial_connect( } let cancel = CancellationToken::new(); - let current_record = Arc::new(Mutex::new(TestRecording::new())); + let current_record = Arc::new(Mutex::new(TactileARecording::new())); let task_record = current_record.clone(); let task_cancel = cancel.clone(); let task_app = app.clone(); @@ -104,10 +119,16 @@ pub async fn serial_connect( let session_started_at = Instant::now(); let task = tauri::async_runtime::spawn(async move { - let codec = TestCodec::new(); - let handler = TestHandler; + let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS); + let handler = TactileAHandler; + let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new( + Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS), + DEFAULT_TACTILE_COLS, + DEFAULT_TACTILE_ROWS, + Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS), + ))); - if let Err(error) = serial::run_serial( + if let Err(error) = serial::run_serial_with_poll( task_app.clone(), port, codec, @@ -115,6 +136,7 @@ pub async fn serial_connect( session_started_at, task_record.clone(), task_cancel, + poll_mode, ) .await { @@ -212,20 +234,6 @@ pub fn serial_export_csv( app: AppHandle, state: State<'_, SerialConnectionState>, ) -> Result { - let current_record = { - let session = state.session.lock().map_err(|_| SerialError::StateError)?; - session - .as_ref() - .map(|current_session| current_session.current_record.clone()) - }; - - let record = if let Some(recording) = current_record { - recording - } else { - let last_record = state.last_record.lock().map_err(|_| SerialError::StateError)?; - last_record.clone().ok_or(SerialError::NoRecordedData)? - }; - let mut output_dir = match app.path().desktop_dir() { Ok(path) => path, Err(_) => std::env::current_dir().map_err(|_| SerialError::ExportError)?, @@ -237,17 +245,8 @@ pub fn serial_export_csv( .unwrap_or_default(); output_dir.push(format!("joyson_export_{timestamp}.csv")); - let mut file = File::create(&output_dir).map_err(|_| SerialError::ExportError)?; - - let frame_count = { - let recording = record.lock().map_err(|_| SerialError::StateError)?; - if recording.frames.is_empty() { - return Err(SerialError::NoRecordedData); - } - - export_recording_csv(&recording, &mut file).map_err(|_| SerialError::ExportError)?; - recording.frames.len() - }; + let record = resolve_record_for_export(&state)?; + let frame_count = write_record_to_csv(record, &output_dir)?; let path = output_dir.display().to_string(); info!("csv exported to {path}, frame_count={frame_count}"); @@ -259,9 +258,40 @@ pub fn serial_export_csv( }) } +#[tauri::command] +pub fn serial_has_record_data( + state: State<'_, SerialConnectionState>, +) -> Result { + let frame_count = snapshot_record_frame_count(&state)?; + + Ok(SerialRecordStateResponse { + has_data: frame_count > 0, + frame_count, + }) +} + +#[tauri::command] +pub fn serial_export_csv_to_path( + file_path: String, + state: State<'_, SerialConnectionState>, +) -> Result { + let output_path = resolve_export_path(file_path)?; + let record = resolve_record_for_export(&state)?; + let frame_count = write_record_to_csv(record, &output_path)?; + let path = output_path.display().to_string(); + + info!("csv exported to {path}, frame_count={frame_count}"); + + Ok(SerialExportResponse { + path, + frame_count, + message: "exported".to_string(), + }) +} + #[tauri::command] pub fn serial_import_csv(file_name: String, csv_content: String) -> Result { - let mut importer = TestCsvImporter::new(file_name.as_str()); + let mut importer = TactileACsvImporter::new(file_name.as_str()); let packets = importer .load(Cursor::new(csv_content.into_bytes())) .map_err(|_| SerialError::ImportError)?; @@ -288,3 +318,128 @@ pub fn serial_import_csv(file_name: String, csv_content: String) -> Result Result { + let path = resolve_import_path(file_path)?; + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "import.csv".to_string()); + + let bytes = std::fs::read(&path).map_err(|_| SerialError::ImportError)?; + let csv_content = String::from_utf8_lossy(&bytes).to_string(); + serial_import_csv(file_name, csv_content) +} + +fn resolve_record_for_export( + state: &State<'_, SerialConnectionState>, +) -> Result { + let current_record = { + let session = state.session.lock().map_err(|_| SerialError::StateError)?; + session + .as_ref() + .map(|current_session| current_session.current_record.clone()) + }; + + if let Some(recording) = current_record { + return Ok(recording); + } + + let last_record = state.last_record.lock().map_err(|_| SerialError::StateError)?; + last_record.clone().ok_or(SerialError::NoRecordedData) +} + +fn snapshot_record_frame_count( + state: &State<'_, SerialConnectionState>, +) -> Result { + let current_record = { + let session = state.session.lock().map_err(|_| SerialError::StateError)?; + session + .as_ref() + .map(|current_session| current_session.current_record.clone()) + }; + + if let Some(record) = current_record { + return record + .lock() + .map(|recording| recording.frames.len()) + .map_err(|_| SerialError::StateError); + } + + let last_record = state.last_record.lock().map_err(|_| SerialError::StateError)?; + let Some(record) = last_record.as_ref() else { + return Ok(0); + }; + + record + .lock() + .map(|recording| recording.frames.len()) + .map_err(|_| SerialError::StateError) +} + +fn write_record_to_csv( + record: SharedTactileRecording, + output_path: &Path, +) -> Result { + if let Some(parent) = output_path.parent() { + if !parent.exists() { + return Err(SerialError::ExportError); + } + } + + let mut file = File::create(output_path).map_err(|_| SerialError::ExportError)?; + let frame_count = { + let recording = record.lock().map_err(|_| SerialError::StateError)?; + if recording.frames.is_empty() { + return Err(SerialError::NoRecordedData); + } + + export_recording_csv(&recording, &mut file).map_err(|_| SerialError::ExportError)?; + recording.frames.len() + }; + + Ok(frame_count) +} + +fn resolve_export_path(raw_path: String) -> Result { + let trimmed = raw_path.trim(); + if trimmed.is_empty() { + return Err(SerialError::ExportError); + } + + let mut path = resolve_absolute_path(trimmed).map_err(|_| SerialError::ExportError)?; + if path.extension().is_none() { + path.set_extension("csv"); + } + + if path.file_name().is_none() { + return Err(SerialError::ExportError); + } + + Ok(path) +} + +fn resolve_import_path(raw_path: String) -> Result { + let trimmed = raw_path.trim(); + if trimmed.is_empty() { + return Err(SerialError::ImportError); + } + + let path = resolve_absolute_path(trimmed).map_err(|_| SerialError::ImportError)?; + if !path.exists() || !path.is_file() { + return Err(SerialError::ImportError); + } + + Ok(path) +} + +fn resolve_absolute_path(raw_path: &str) -> std::io::Result { + let path = PathBuf::from(raw_path); + if path.is_absolute() { + Ok(path) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4a0f5f..ffd5864 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,11 +9,15 @@ pub fn run() { .manage(SerialConnectionState::default()) .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ + commands::file_explorer::file_explorer_list, commands::serial::serial_enum, commands::serial::serial_connect, commands::serial::serial_disconnect, commands::serial::serial_export_csv, + commands::serial::serial_has_record_data, + commands::serial::serial_export_csv_to_path, commands::serial::serial_import_csv, + commands::serial::serial_import_csv_from_path, commands::window::win_minimize, commands::window::win_toggle_maximize, commands::window::win_close diff --git a/src-tauri/src/serial_core/codecs/tactile_a.rs b/src-tauri/src/serial_core/codecs/tactile_a.rs index 2a1a8d9..7f3a7f5 100644 --- a/src-tauri/src/serial_core/codecs/tactile_a.rs +++ b/src-tauri/src/serial_core/codecs/tactile_a.rs @@ -1,16 +1,18 @@ use crate::serial_core::error::CodecError; use crate::serial_core::frame::{ - FrameHandler, TactileAFrameMetaData, TactileARepFrame, TactileAReqFrame, TestFrame, + FrameHandler, TactileAFrameMetaData, TactileARepFrame, TactileAReqFrame, }; -use crate::serial_core::record::Recording; -use crate::serial_core::record::{self, CsvExporter}; +use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording}; use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis, usize_to_u16_le_bytes}; use crate::serial_core::{ codec::Codec, frame::{TactileAFrame, TactileAFrameStatusCode}, }; use async_trait::async_trait; -use std::time::Instant; +use csv::StringRecord; +use anyhow::anyhow; +use std::io::Read; + const FRAME_BUFFER_MIN_LENGTH: usize = 15; pub struct TactileACodec { @@ -234,7 +236,7 @@ impl CsvExporter for TactileACsvExporter { fn csv_row( &self, - item: &record::RecordedFrame, + item: &RecordedFrame, ) -> anyhow::Result> { let packet = TactileADataPacket::try_from(&item.frame)?; let mut row: Vec = packet.data.iter().map(|x| x.to_string()).collect(); @@ -243,5 +245,103 @@ impl CsvExporter for TactileACsvExporter { } } -#[cfg(test)] -mod tests {} +impl CsvExporter for TactileACsvExporter { + type Error = CodecError; + + fn csv_header(&self, _recording: &Recording) -> Vec { + let mut header: Vec = Vec::new(); + for i in 0..self.channels { + header.push(format!("channel{}", i + 1)); + } + + header.push("dts".to_string()); + header + } + + fn csv_row( + &self, + item: &RecordedFrame, + ) -> anyhow::Result> { + let rep = match &item.frame { + TactileAFrame::Rep(rep) => rep, + TactileAFrame::Req(_) => return Err(anyhow!("request frame cannot be exported to csv row")), + }; + + let packet = TactileADataPacket::try_from(rep)?; + let mut row: Vec = packet.data.iter().map(|x| x.to_string()).collect(); + row.push(packet.dts_ms.to_string()); + Ok(row) + } +} + +impl TactileACsvImporter { + pub fn new(_path: &str) -> TactileACsvImporter { + Self { + channels: 0, + data_row: 0, + packets: Vec::new(), + } + } + + fn parse_record(&mut self, record: StringRecord) -> anyhow::Result { + if self.channels == 0 { + return Err(anyhow!("csv header is missing channel columns")); + } + + if record.len() < self.channels + 1 { + return Err(anyhow!("csv row has insufficient columns")); + } + + 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"))?; + data.push(cell.parse::()?); + } + + let dts_cell = record + .get(self.channels) + .ok_or_else(|| anyhow!("missing dts cell"))?; + let dts_ms = dts_cell.parse::()?; + + Ok(TactileADataPacket { + data: data, + dts_ms: dts_ms, + }) + } +} + +impl CsvImporter for TactileACsvImporter { + fn load(&mut self, reader: R) -> anyhow::Result> { + let mut rdr = csv::Reader::from_reader(reader); + let headers = rdr.headers()?.clone(); + self.channels = headers.len().saturating_sub(1); + self.data_row = 0; + self.packets.clear(); + + for record in rdr.records() { + let record = record?; + let packet = self.parse_record(record)?; + self.packets.push(packet); + self.data_row += 1; + } + + Ok(self.packets.clone()) + } +} + +pub fn export_recording_csv(recording: &Recording, writer: W) -> anyhow::Result<()> +where + W: std::io::Write, +{ + let channel_nb = recording + .frames + .iter() + .find_map(|frame| match &frame.frame { + TactileAFrame::Rep(rep) => Some(rep.payload.len() / 2), + TactileAFrame::Req(_) => None, + }) + .unwrap_or(0); + + let exporter = TactileACsvExporter::new(channel_nb); + write_csv(recording, &exporter, writer) +} diff --git a/src-tauri/src/serial_core/codecs/test.rs b/src-tauri/src/serial_core/codecs/test.rs index 07f53c9..f3d40d7 100644 --- a/src-tauri/src/serial_core/codecs/test.rs +++ b/src-tauri/src/serial_core/codecs/test.rs @@ -4,7 +4,6 @@ use crate::serial_core::frame::{FrameHandler}; use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame}; use anyhow::anyhow; use async_trait::async_trait; -use chrono::Local; use csv::StringRecord; use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording}; use crc::{Crc, CRC_8_SMBUS}; @@ -233,9 +232,7 @@ pub fn export_recording_csv(recording: &Recording, writer: W) -> a where W: std::io::Write, { - let now = Local::now(); - let filename = format!("joyson_{}", now.format("%Y%m%d_%H%M%S")); - write_csv(recording, &TestCsvExporter, &filename) + write_csv(recording, &TestCsvExporter, writer) } #[cfg(test)] diff --git a/src-tauri/src/serial_core/mod.rs b/src-tauri/src/serial_core/mod.rs index a3d8783..a3adf2c 100644 --- a/src-tauri/src/serial_core/mod.rs +++ b/src-tauri/src/serial_core/mod.rs @@ -1,4 +1,7 @@ -use crate::serial_core::{frame::TestFrame, record::Recording}; +use crate::serial_core::{ + frame::{TactileAFrame, TestFrame}, + record::Recording, +}; pub mod codec; pub mod codecs; @@ -10,6 +13,7 @@ pub mod record; pub mod utils; pub type TestRecording = Recording; +pub type TactileARecording = Recording; pub struct SerialConnection { pub port: String, diff --git a/src-tauri/src/serial_core/record.rs b/src-tauri/src/serial_core/record.rs index 48c4b6c..7a20d35 100644 --- a/src-tauri/src/serial_core/record.rs +++ b/src-tauri/src/serial_core/record.rs @@ -1,8 +1,3 @@ -use std::fs::{write, File}; -use std::io; -use anyhow::{Result, anyhow}; -use csv::Reader; - #[derive(Clone)] pub struct FrameTiming { pub pts_ms: Option, @@ -38,20 +33,17 @@ pub trait CsvImporter

{ fn load(&mut self, reader: R) -> anyhow::Result>; } -pub fn write_csv( +pub fn write_csv( recording: &Recording, exporter: &E, - path: &str - // mut writer: W, + writer: W, ) -> anyhow::Result<()> where E: CsvExporter, - // W: std::io::Write + W: std::io::Write, { let header = exporter.csv_header(&recording); - // let mut wrt = csv::Writer::from_writer(io::stdout()); - - let mut wrt = csv::Writer::from_path(format!("{}.csv", path))?; + let mut wrt = csv::Writer::from_writer(writer); wrt.write_record(header)?; for f in &recording.frames { let row = exporter.csv_row(f)?; diff --git a/src-tauri/src/serial_core/serial.rs b/src-tauri/src/serial_core/serial.rs index 0322964..687058b 100644 --- a/src-tauri/src/serial_core/serial.rs +++ b/src-tauri/src/serial_core/serial.rs @@ -54,10 +54,24 @@ impl SerialFrame for TactileAFrame { fn to_hud_packet( &self, - _chart_state: &mut HudChartState, - _display_values: Option<&[i32]>, + chart_state: &mut HudChartState, + display_values: Option<&[i32]>, ) -> Option { - None + match self { + TactileAFrame::Req(_) => None, + TactileAFrame::Rep(rep) => { + let proxy = TestFrame { + header: rep.meta.header, + cmd: rep.meta.func_code, + length: rep.meta.except_data_len, + payload: rep.payload.clone(), + checksum: rep.meta.checksum, + dts_ms: rep.dts_ms, + }; + + Some(chart_state.apply_frame(&proxy, display_values)) + } + } } } diff --git a/src/lib/components/FileExplorerModal.svelte b/src/lib/components/FileExplorerModal.svelte new file mode 100644 index 0000000..6f1b614 --- /dev/null +++ b/src/lib/components/FileExplorerModal.svelte @@ -0,0 +1,833 @@ + + +{#if open} +

+{/if} + + diff --git a/src/lib/components/HudPanel.svelte b/src/lib/components/HudPanel.svelte index b52c82b..d5b9270 100644 --- a/src/lib/components/HudPanel.svelte +++ b/src/lib/components/HudPanel.svelte @@ -41,7 +41,6 @@ export let isExporting = false; export let isExportDisabled = false; export let isWindowMaximized = false; - let csvInputEl: HTMLInputElement | undefined; const dispatch = createEventDispatcher<{ windowcontrol: WindowControlAction; @@ -51,7 +50,8 @@ serialrefresh: void; serialconnect: string; serialexport: void; - csvimport: File; + csvimport: void; + noticeclear: void; }>(); const connectionToneByState: Record = { @@ -106,17 +106,12 @@ dispatch("serialexport"); } - function openCsvPicker(): void { - csvInputEl?.click(); + function emitCsvImport(): void { + dispatch("csvimport"); } - function emitCsvImport(event: Event): void { - const target = event.currentTarget as HTMLInputElement; - const file = target.files?.[0]; - if (file) { - dispatch("csvimport", file); - } - target.value = ""; + function emitNoticeClear(): void { + dispatch("noticeclear"); } @@ -246,7 +241,7 @@ {exportButtonText} - -
+ {/if}
@@ -724,20 +720,22 @@ 0 0 12px rgb(122 198 255 / 0.14); } - .hidden-input { - position: absolute; - inline-size: 0; - block-size: 0; - opacity: 0; - pointer-events: none; - } - .connection-notice { margin: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; border: 1px solid rgb(95 132 158 / 0.32); border-radius: 0.5rem; - padding: 0.45rem 0.7rem; + padding: 0.38rem 0.45rem 0.38rem 0.7rem; background: rgb(8 14 19 / 0.72); + } + + .connection-notice-text { + margin: 0; + flex: 1; + min-width: 0; color: rgb(214 236 248 / 0.96); font-size: 0.72rem; letter-spacing: 0.03em; @@ -758,7 +756,41 @@ .connection-notice.tone-info { border-color: rgb(62 232 255 / 0.34); background: rgb(8 17 22 / 0.76); - color: rgb(214 236 248 / 0.96); + } + + .notice-close-btn { + inline-size: 1.36rem; + block-size: 1.36rem; + border: 1px solid rgb(116 151 176 / 0.4); + border-radius: 0.28rem; + background: rgb(7 12 16 / 0.82); + color: rgb(194 225 245 / 0.92); + font-size: 0.92rem; + line-height: 1; + cursor: pointer; + flex-shrink: 0; + transition: + border-color 180ms ease, + color 180ms ease, + background-color 180ms ease; + } + + .notice-close-btn:hover { + border-color: rgb(62 232 255 / 0.5); + color: rgb(237 250 255 / 0.98); + background: rgb(9 16 22 / 0.92); + } + + .connection-notice.tone-warn .notice-close-btn:hover { + border-color: rgb(255 91 63 / 0.6); + color: rgb(255 227 220 / 0.98); + background: rgb(34 13 12 / 0.9); + } + + .connection-notice.tone-ok .notice-close-btn:hover { + border-color: rgb(133 255 68 / 0.56); + color: rgb(236 255 227 / 0.98); + background: rgb(17 28 14 / 0.9); } .info-grid { diff --git a/src/lib/components/PressureMatrixViewer.svelte b/src/lib/components/PressureMatrixViewer.svelte index 03caa43..9680865 100644 --- a/src/lib/components/PressureMatrixViewer.svelte +++ b/src/lib/components/PressureMatrixViewer.svelte @@ -49,8 +49,8 @@ const MAX_LABEL_SCALE = 2.45; const MATRIX_OFFSET_Y = -2.4; const MATRIX_OFFSET_Z = 12; - const HEIGHT_SCALE = 18.5; - const BASE_HEIGHT = 0.18; + const HEIGHT_SCALE = 10.6; + const BASE_HEIGHT = 0.12; const GLOW_START = 0.3; const SMOOTHING_SPEED = 8.2; const CAMERA_FOV = 36; @@ -152,7 +152,7 @@ } function shapeHeightValue(valueNormalized: number): number { - return Math.pow(clamp(valueNormalized, 0, 1), 0.74); + return Math.pow(clamp(valueNormalized, 0, 1), 0.9); } function shapeGlowStrength(valueNormalized: number): number { @@ -170,7 +170,7 @@ const gridSpan = Math.max(boardWidth, boardDepth) + boardPadding * 2; const gridDivisions = Math.round(clamp(longestEdge * 1.6, MIN_GRID_DIVISIONS, MAX_GRID_DIVISIONS)); const labelScale = clamp(24 / (longestEdge + 2), MIN_LABEL_SCALE, MAX_LABEL_SCALE); - const labelFloatOffset = clamp(cellSpacing * 0.74, 0.64, 2.2); + const labelFloatOffset = clamp(cellSpacing * 0.42, 0.36, 1.12); return { cellSpacing, diff --git a/src/lib/types/hud.ts b/src/lib/types/hud.ts index b309673..b1ec634 100644 --- a/src/lib/types/hud.ts +++ b/src/lib/types/hud.ts @@ -99,6 +99,20 @@ export interface HudCopy { exportActionLabel: string; exportingActionLabel: string; importActionLabel: string; + fileExplorerImportTitle: string; + fileExplorerExportTitle: string; + fileExplorerPathLabel: string; + fileExplorerNameLabel: string; + fileExplorerCancelLabel: string; + fileExplorerOpenLabel: string; + fileExplorerSaveLabel: string; + fileExplorerEmptyHint: string; + fileExplorerCsvHint: string; + fileExplorerLoadingLabel: string; + fileExplorerUpLabel: string; + fileExplorerNameColumnLabel: string; + fileExplorerSizeColumnLabel: string; + fileExplorerModifiedColumnLabel: string; replaySectionLabel: string; replayPlayLabel: string; replayPauseLabel: string; @@ -131,6 +145,11 @@ export interface SerialExportResult { message: string; } +export interface SerialRecordStateResult { + hasData: boolean; + frameCount: number; +} + export interface SerialImportFrameResult { data: number[]; dtsMs: number; @@ -143,3 +162,23 @@ export interface SerialImportResult { frames: SerialImportFrameResult[]; message: string; } + +export interface FileExplorerRoot { + label: string; + path: string; +} + +export interface FileExplorerEntry { + name: string; + path: string; + isDir: boolean; + sizeBytes: number | null; + modifiedMs: number | null; +} + +export interface FileExplorerListResult { + currentPath: string; + parentPath: string | null; + roots: FileExplorerRoot[]; + entries: FileExplorerEntry[]; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b5c95f4..1836f0e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5,10 +5,14 @@ import { LogicalSize, currentMonitor, getCurrentWindow } from "@tauri-apps/api/window"; import HudPanel from "$lib/components/HudPanel.svelte"; import CenterStage from "$lib/components/CenterStage.svelte"; + import FileExplorerModal from "$lib/components/FileExplorerModal.svelte"; import { pressureColorPalettes } from "$lib/config/color-map"; import "$lib/styles/theme.css"; import type { ConnectionState, + FileExplorerEntry, + FileExplorerListResult, + FileExplorerRoot, HudColorMapOption, HudCopy, HudConfigLink, @@ -21,6 +25,7 @@ LocaleCode, SerialConnectResult, SerialExportResult, + SerialRecordStateResult, SerialImportResult, SignalTone, StageStatusTone, @@ -28,6 +33,8 @@ } from "$lib/types/hud"; type SignalPanelTemplate = Pick; + type FileExplorerMode = "open" | "save"; + interface ReplayFrame { values: number[]; dtsMs: number; @@ -65,6 +72,20 @@ exportActionLabel: "导出 CSV", exportingActionLabel: "导出中", importActionLabel: "导入 CSV", + fileExplorerImportTitle: "导入 CSV 文件", + fileExplorerExportTitle: "导出 CSV 文件", + fileExplorerPathLabel: "路径", + fileExplorerNameLabel: "文件名", + fileExplorerCancelLabel: "取消", + fileExplorerOpenLabel: "打开", + fileExplorerSaveLabel: "保存", + fileExplorerEmptyHint: "当前目录下没有可用条目", + fileExplorerCsvHint: "仅显示 *.csv 文件", + fileExplorerLoadingLabel: "处理中...", + fileExplorerUpLabel: "↑ 上一级", + fileExplorerNameColumnLabel: "名称", + fileExplorerSizeColumnLabel: "大小", + fileExplorerModifiedColumnLabel: "修改时间", replaySectionLabel: "回放", replayPlayLabel: "播放", replayPauseLabel: "暂停", @@ -107,6 +128,20 @@ exportActionLabel: "Export CSV", exportingActionLabel: "Exporting", importActionLabel: "Import CSV", + fileExplorerImportTitle: "Import CSV File", + fileExplorerExportTitle: "Export CSV File", + fileExplorerPathLabel: "Path", + fileExplorerNameLabel: "File Name", + fileExplorerCancelLabel: "Cancel", + fileExplorerOpenLabel: "Open", + fileExplorerSaveLabel: "Save", + fileExplorerEmptyHint: "No entries in this directory", + fileExplorerCsvHint: "Only *.csv files are listed", + fileExplorerLoadingLabel: "Processing...", + fileExplorerUpLabel: "↑ Up", + fileExplorerNameColumnLabel: "Name", + fileExplorerSizeColumnLabel: "Size", + fileExplorerModifiedColumnLabel: "Modified", replaySectionLabel: "Replay", replayPlayLabel: "Play", replayPauseLabel: "Pause", @@ -186,6 +221,15 @@ let replayProgress = 0; let replayFileName = ""; let replayTimerId: number | null = null; + let fileExplorerOpen = false; + let fileExplorerMode: FileExplorerMode = "open"; + let fileExplorerBusy = false; + let fileExplorerCurrentPath = ""; + let fileExplorerParentPath: string | null = null; + let fileExplorerEntries: FileExplorerEntry[] = []; + let fileExplorerRoots: FileExplorerRoot[] = []; + let fileExplorerSelectedPath = ""; + let fileExplorerFileName = ""; $: uiCopy = copyByLocale[locale]; $: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen); @@ -197,6 +241,10 @@ $: rangeScaleStyle = buildRangeScaleStyle(colorMapPreset); $: replayHasData = replayFrames.length > 0; $: replayFrameInfo = replayHasData ? `${replayHasDisplayedFrame ? replayCurrentIndex + 1 : 0}/${replayFrames.length}` : ""; + $: fileExplorerTitle = + fileExplorerMode === "open" ? uiCopy.fileExplorerImportTitle : uiCopy.fileExplorerExportTitle; + $: fileExplorerConfirmLabel = + fileExplorerMode === "open" ? uiCopy.fileExplorerOpenLabel : uiCopy.fileExplorerSaveLabel; function isTauriRuntime(): boolean { return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; @@ -353,6 +401,209 @@ return frames; } + function buildDefaultExportName(): string { + const now = new Date(); + const pad = (value: number) => String(value).padStart(2, "0"); + return `joyson_export_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.csv`; + } + + function ensureCsvSuffix(fileName: string): string { + const trimmed = fileName.trim(); + if (!trimmed) { + return ""; + } + + return trimmed.toLowerCase().endsWith(".csv") ? trimmed : `${trimmed}.csv`; + } + + function inferPathSeparator(path: string): string { + return path.includes("\\") ? "\\" : "/"; + } + + function joinPath(parent: string, fileName: string): string { + const safeParent = parent.trim(); + if (!safeParent) { + return fileName; + } + + const separator = inferPathSeparator(safeParent); + if (safeParent.endsWith(separator)) { + return `${safeParent}${fileName}`; + } + + return `${safeParent}${separator}${fileName}`; + } + + function isCsvPath(path: string): boolean { + return path.toLowerCase().endsWith(".csv"); + } + + function applyImportedFrames(fileName: string, frames: ReplayFrame[], frameCount: number, channelCount: number): void { + if (!frames.length) { + throw new Error("EmptyReplayData"); + } + + replayFrames = frames; + replayFileName = fileName; + replayCurrentIndex = 0; + replayHasDisplayedFrame = false; + replayProgress = 0; + resetReplayVisualState(); + + connectionNotice = + locale === "zh-CN" + ? `已导入 ${fileName},共 ${frameCount} 帧 / ${channelCount} 通道,可开始回放。` + : `${fileName} loaded (${frameCount} frames / ${channelCount} channels). Ready to replay.`; + connectionNoticeTone = "ok"; + } + + async function loadFileExplorerDirectory(path?: string): Promise { + if (!isTauriRuntime()) { + return; + } + + fileExplorerBusy = true; + try { + const result = await invoke("file_explorer_list", { + path, + extensions: fileExplorerMode === "open" ? ["csv"] : undefined + }); + + fileExplorerCurrentPath = result.currentPath; + fileExplorerParentPath = result.parentPath; + fileExplorerRoots = result.roots; + fileExplorerEntries = result.entries; + + const selectedExists = fileExplorerEntries.some((entry) => entry.path === fileExplorerSelectedPath); + if (!selectedExists) { + fileExplorerSelectedPath = ""; + } + } catch (error) { + connectionNotice = + locale === "zh-CN" ? "文件浏览器加载失败,请检查目录权限。" : "File explorer failed to load. Check directory permissions."; + connectionNoticeTone = "warn"; + console.error("File explorer load failed:", error); + } finally { + fileExplorerBusy = false; + } + } + + async function openFileExplorer(mode: FileExplorerMode): Promise { + if (!isTauriRuntime()) { + if (mode === "open") { + await importViaBrowserInput(); + return; + } + + await runSerialExport(); + return; + } + + fileExplorerMode = mode; + fileExplorerOpen = true; + fileExplorerBusy = false; + fileExplorerSelectedPath = ""; + if (mode === "save") { + fileExplorerFileName = buildDefaultExportName(); + } else { + fileExplorerFileName = ""; + } + + await loadFileExplorerDirectory(fileExplorerCurrentPath || undefined); + } + + function closeFileExplorer(): void { + if (fileExplorerBusy) { + return; + } + + fileExplorerOpen = false; + } + + async function importViaBrowserInput(): Promise { + if (typeof document === "undefined") { + return; + } + + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".csv,text/csv"; + + const selectedFile = await new Promise((resolve) => { + input.onchange = () => resolve(input.files?.[0] ?? null); + input.click(); + }); + + if (!selectedFile) { + return; + } + + await importReplayFromFile(selectedFile); + } + + async function importReplayFromFile(file: File): Promise { + if (!file) { + return false; + } + + pauseReplayPlayback(); + + try { + const text = await file.text(); + let frames: ReplayFrame[] = []; + let importedFrameCount = 0; + let importedChannelCount = 0; + + if (isTauriRuntime()) { + const result = await invoke("serial_import_csv", { + fileName: file.name, + csvContent: text + }); + frames = result.frames.map((frame) => ({ + values: frame.data, + dtsMs: frame.dtsMs + })); + importedFrameCount = result.frameCount; + importedChannelCount = result.channelCount; + } else { + frames = parseReplayCsv(text); + importedFrameCount = frames.length; + importedChannelCount = frames[0]?.values.length ?? 0; + } + + applyImportedFrames(file.name, frames, importedFrameCount, importedChannelCount); + return true; + } catch (error) { + connectionNotice = resolveImportNotice(error); + connectionNoticeTone = "warn"; + console.error("Replay import failed:", error); + return false; + } + } + + async function importReplayFromPath(path: string): Promise { + pauseReplayPlayback(); + + try { + const result = await invoke("serial_import_csv_from_path", { + filePath: path + }); + + const frames = result.frames.map((frame) => ({ + values: frame.data, + dtsMs: frame.dtsMs + })); + + applyImportedFrames(result.fileName, frames, result.frameCount, result.channelCount); + return true; + } catch (error) { + connectionNotice = resolveImportNotice(error); + connectionNoticeTone = "warn"; + console.error("Replay import failed:", error); + return false; + } + } + function stopReplayTimer(): void { if (replayTimerId == null || typeof window === "undefined") { return; @@ -980,81 +1231,118 @@ } } - async function handleSerialExport(): Promise { + async function runSerialExport(filePath?: string): Promise { if (!isTauriRuntime()) { - console.warn("[serial] Export is only available inside Tauri."); - return; + connectionNotice = + locale === "zh-CN" ? "当前环境不支持导出到本地路径。" : "Current runtime cannot export to local paths."; + connectionNoticeTone = "warn"; + return false; } isExporting = true; + fileExplorerBusy = true; try { - const result = await invoke("serial_export_csv"); + const result = filePath + ? await invoke("serial_export_csv_to_path", { filePath }) + : await invoke("serial_export_csv"); + connectionNotice = locale === "zh-CN" ? `CSV 导出成功(${result.frameCount} 帧):${result.path}` : `CSV exported (${result.frameCount} frames): ${result.path}`; connectionNoticeTone = "ok"; + return true; } catch (error) { connectionNotice = resolveExportNotice(error); connectionNoticeTone = "warn"; console.error("Serial export failed:", error); + return false; } finally { isExporting = false; + fileExplorerBusy = false; } } - async function handleReplayImport(event: CustomEvent): Promise { - const file = event.detail; - if (!file) { + async function precheckExportRecordData(): Promise { + if (!isTauriRuntime()) { + return true; + } + + try { + const result = await invoke("serial_has_record_data"); + if (result.hasData) { + return true; + } + + connectionNotice = resolveExportNotice("NoRecordedData"); + connectionNoticeTone = "warn"; + return false; + } catch (error) { + connectionNotice = resolveExportNotice(error); + connectionNoticeTone = "warn"; + console.error("Export precheck failed:", error); + return false; + } + } + + async function handleSerialExportRequest(): Promise { + const hasData = await precheckExportRecordData(); + if (!hasData) { return; } - pauseReplayPlayback(); + await openFileExplorer("save"); + } - try { - const text = await file.text(); - let frames: ReplayFrame[]; - let importedFrameCount = 0; - let importedChannelCount = 0; + async function handleReplayImportRequest(): Promise { + await openFileExplorer("open"); + } - if (isTauriRuntime()) { - const result = await invoke("serial_import_csv", { - fileName: file.name, - csvContent: text - }); - frames = result.frames.map((frame) => ({ - values: frame.data, - dtsMs: frame.dtsMs - })); - importedFrameCount = result.frameCount; - importedChannelCount = result.channelCount; - } else { - frames = parseReplayCsv(text); - importedFrameCount = frames.length; - importedChannelCount = frames[0]?.values.length ?? 0; + async function handleFileExplorerNavigate(event: CustomEvent): Promise { + await loadFileExplorerDirectory(event.detail); + } + + async function handleFileExplorerConfirm(): Promise { + if (fileExplorerBusy) { + return; + } + + if (fileExplorerMode === "open") { + const selected = fileExplorerEntries.find((entry) => entry.path === fileExplorerSelectedPath); + if (!selected) { + return; + } + if (selected.isDir) { + await loadFileExplorerDirectory(selected.path); + return; + } + if (!isCsvPath(selected.path)) { + connectionNotice = locale === "zh-CN" ? "请先选择 CSV 文件。" : "Please choose a CSV file."; + connectionNoticeTone = "warn"; + return; } - if (!frames.length) { - throw new Error("EmptyReplayData"); + fileExplorerBusy = true; + const ok = await importReplayFromPath(selected.path); + fileExplorerBusy = false; + if (ok) { + fileExplorerOpen = false; } + return; + } - replayFrames = frames; - replayFileName = file.name; - replayCurrentIndex = 0; - replayHasDisplayedFrame = false; - replayProgress = 0; - resetReplayVisualState(); + const selected = fileExplorerEntries.find((entry) => entry.path === fileExplorerSelectedPath); + const targetDir = selected?.isDir ? selected.path : fileExplorerCurrentPath; + const csvName = ensureCsvSuffix(fileExplorerFileName || buildDefaultExportName()); + if (!csvName) { + return; + } - connectionNotice = - locale === "zh-CN" - ? `已导入 ${file.name},共 ${importedFrameCount} 帧 / ${importedChannelCount} 通道,可开始回放。` - : `${file.name} loaded (${importedFrameCount} frames / ${importedChannelCount} channels). Ready to replay.`; - connectionNoticeTone = "ok"; - } catch (error) { - connectionNotice = resolveImportNotice(error); - connectionNoticeTone = "warn"; - console.error("Replay import failed:", error); + const targetPath = joinPath(targetDir, csvName); + const ok = await runSerialExport(targetPath); + if (ok) { + fileExplorerOpen = false; } } @@ -1228,8 +1516,9 @@ on:configlink={handleConfigLink} on:serialrefresh={handleSerialRefresh} on:serialconnect={handleSerialConnect} - on:serialexport={handleSerialExport} - on:csvimport={handleReplayImport} + on:serialexport={handleSerialExportRequest} + on:csvimport={handleReplayImportRequest} + on:noticeclear={() => (connectionNotice = "")} /> + +