exchange tast to tactilea
This commit is contained in:
208
src-tauri/src/commands/file_explorer.rs
Normal file
208
src-tauri/src/commands/file_explorer.rs
Normal file
@@ -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<u64>,
|
||||
pub modified_ms: Option<u128>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileExplorerListResponse {
|
||||
pub current_path: String,
|
||||
pub parent_path: Option<String>,
|
||||
pub roots: Vec<FileExplorerRoot>,
|
||||
pub entries: Vec<FileExplorerEntry>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn file_explorer_list(
|
||||
app: AppHandle,
|
||||
path: Option<String>,
|
||||
extensions: Option<Vec<String>>,
|
||||
) -> Result<FileExplorerListResponse, String> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<Vec<String>>) -> HashSet<String> {
|
||||
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<String>) -> Result<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
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<FileExplorerRoot> {
|
||||
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
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod file_explorer;
|
||||
pub mod serial;
|
||||
pub mod window;
|
||||
|
||||
@@ -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<Mutex<TestRecording>>;
|
||||
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<Mutex<TactileARecording>>;
|
||||
|
||||
|
||||
#[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<Option<SerialSession>>,
|
||||
last_record: Mutex<Option<SharedTestRecording>>
|
||||
last_record: Mutex<Option<SharedTactileRecording>>
|
||||
}
|
||||
|
||||
#[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<SerialExportResponse, SerialError> {
|
||||
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<SerialRecordStateResponse, SerialError> {
|
||||
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<SerialExportResponse, SerialError> {
|
||||
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<SerialImportResponse, SerialError> {
|
||||
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<Seria
|
||||
message: "imported".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn serial_import_csv_from_path(file_path: String) -> Result<SerialImportResponse, SerialError> {
|
||||
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<SharedTactileRecording, SerialError> {
|
||||
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<usize, SerialError> {
|
||||
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<usize, SerialError> {
|
||||
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<PathBuf, SerialError> {
|
||||
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<PathBuf, SerialError> {
|
||||
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<PathBuf> {
|
||||
let path = PathBuf::from(raw_path);
|
||||
if path.is_absolute() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Ok(std::env::current_dir()?.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user