exchange tast to tactilea
This commit is contained in:
0
src-tauri/program.log2026-04-03
Normal file
0
src-tauri/program.log2026-04-03
Normal file
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 serial;
|
||||||
pub mod window;
|
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::error::SerialError;
|
||||||
use crate::serial_core::record::CsvImporter;
|
use crate::serial_core::record::CsvImporter;
|
||||||
use crate::serial_core::serial::PollMode;
|
use crate::serial_core::serial::{PollMode, TactileAPollRequester};
|
||||||
use crate::serial_core::{TestRecording, serial};
|
use crate::serial_core::{serial, TactileARecording};
|
||||||
use log::info;
|
use log::info;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
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 tauri::{async_runtime::JoinHandle, AppHandle, Manager, State};
|
||||||
use tokio_serial::{available_ports, SerialPortBuilderExt};
|
use tokio_serial::{available_ports, SerialPortBuilderExt};
|
||||||
use tokio_util::sync::CancellationToken;
|
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)]
|
#[derive(Serialize)]
|
||||||
@@ -49,17 +57,24 @@ pub struct SerialImportResponse {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SerialRecordStateResponse {
|
||||||
|
pub has_data: bool,
|
||||||
|
pub frame_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
struct SerialSession {
|
struct SerialSession {
|
||||||
port: String,
|
port: String,
|
||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
task: JoinHandle<()>,
|
task: JoinHandle<()>,
|
||||||
current_record: SharedTestRecording,
|
current_record: SharedTactileRecording,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct SerialConnectionState {
|
pub struct SerialConnectionState {
|
||||||
session: Mutex<Option<SerialSession>>,
|
session: Mutex<Option<SerialSession>>,
|
||||||
last_record: Mutex<Option<SharedTestRecording>>
|
last_record: Mutex<Option<SharedTactileRecording>>
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -92,7 +107,7 @@ pub async fn serial_connect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancel = CancellationToken::new();
|
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_record = current_record.clone();
|
||||||
let task_cancel = cancel.clone();
|
let task_cancel = cancel.clone();
|
||||||
let task_app = app.clone();
|
let task_app = app.clone();
|
||||||
@@ -104,10 +119,16 @@ pub async fn serial_connect(
|
|||||||
let session_started_at = Instant::now();
|
let session_started_at = Instant::now();
|
||||||
|
|
||||||
let task = tauri::async_runtime::spawn(async move {
|
let task = tauri::async_runtime::spawn(async move {
|
||||||
let codec = TestCodec::new();
|
let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS);
|
||||||
let handler = TestHandler;
|
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(),
|
task_app.clone(),
|
||||||
port,
|
port,
|
||||||
codec,
|
codec,
|
||||||
@@ -115,6 +136,7 @@ pub async fn serial_connect(
|
|||||||
session_started_at,
|
session_started_at,
|
||||||
task_record.clone(),
|
task_record.clone(),
|
||||||
task_cancel,
|
task_cancel,
|
||||||
|
poll_mode,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -212,20 +234,6 @@ pub fn serial_export_csv(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, SerialConnectionState>,
|
state: State<'_, SerialConnectionState>,
|
||||||
) -> Result<SerialExportResponse, SerialError> {
|
) -> 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() {
|
let mut output_dir = match app.path().desktop_dir() {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
Err(_) => std::env::current_dir().map_err(|_| SerialError::ExportError)?,
|
Err(_) => std::env::current_dir().map_err(|_| SerialError::ExportError)?,
|
||||||
@@ -237,17 +245,8 @@ pub fn serial_export_csv(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
output_dir.push(format!("joyson_export_{timestamp}.csv"));
|
output_dir.push(format!("joyson_export_{timestamp}.csv"));
|
||||||
let mut file = File::create(&output_dir).map_err(|_| SerialError::ExportError)?;
|
let record = resolve_record_for_export(&state)?;
|
||||||
|
let frame_count = write_record_to_csv(record, &output_dir)?;
|
||||||
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 path = output_dir.display().to_string();
|
let path = output_dir.display().to_string();
|
||||||
info!("csv exported to {path}, frame_count={frame_count}");
|
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]
|
#[tauri::command]
|
||||||
pub fn serial_import_csv(file_name: String, csv_content: String) -> Result<SerialImportResponse, SerialError> {
|
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
|
let packets = importer
|
||||||
.load(Cursor::new(csv_content.into_bytes()))
|
.load(Cursor::new(csv_content.into_bytes()))
|
||||||
.map_err(|_| SerialError::ImportError)?;
|
.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(),
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ pub fn run() {
|
|||||||
.manage(SerialConnectionState::default())
|
.manage(SerialConnectionState::default())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::file_explorer::file_explorer_list,
|
||||||
commands::serial::serial_enum,
|
commands::serial::serial_enum,
|
||||||
commands::serial::serial_connect,
|
commands::serial::serial_connect,
|
||||||
commands::serial::serial_disconnect,
|
commands::serial::serial_disconnect,
|
||||||
commands::serial::serial_export_csv,
|
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,
|
||||||
|
commands::serial::serial_import_csv_from_path,
|
||||||
commands::window::win_minimize,
|
commands::window::win_minimize,
|
||||||
commands::window::win_toggle_maximize,
|
commands::window::win_toggle_maximize,
|
||||||
commands::window::win_close
|
commands::window::win_close
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
use crate::serial_core::error::CodecError;
|
use crate::serial_core::error::CodecError;
|
||||||
use crate::serial_core::frame::{
|
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::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
|
||||||
use crate::serial_core::record::{self, CsvExporter};
|
|
||||||
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis, usize_to_u16_le_bytes};
|
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis, usize_to_u16_le_bytes};
|
||||||
use crate::serial_core::{
|
use crate::serial_core::{
|
||||||
codec::Codec,
|
codec::Codec,
|
||||||
frame::{TactileAFrame, TactileAFrameStatusCode},
|
frame::{TactileAFrame, TactileAFrameStatusCode},
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
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;
|
const FRAME_BUFFER_MIN_LENGTH: usize = 15;
|
||||||
|
|
||||||
pub struct TactileACodec {
|
pub struct TactileACodec {
|
||||||
@@ -234,7 +236,7 @@ impl CsvExporter<TactileARepFrame> for TactileACsvExporter {
|
|||||||
|
|
||||||
fn csv_row(
|
fn csv_row(
|
||||||
&self,
|
&self,
|
||||||
item: &record::RecordedFrame<TactileARepFrame>,
|
item: &RecordedFrame<TactileARepFrame>,
|
||||||
) -> anyhow::Result<Vec<String>> {
|
) -> anyhow::Result<Vec<String>> {
|
||||||
let packet = TactileADataPacket::try_from(&item.frame)?;
|
let packet = TactileADataPacket::try_from(&item.frame)?;
|
||||||
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
|
let mut row: Vec<String> = packet.data.iter().map(|x| x.to_string()).collect();
|
||||||
@@ -243,5 +245,103 @@ impl CsvExporter<TactileARepFrame> for TactileACsvExporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
impl CsvExporter<TactileAFrame> for TactileACsvExporter {
|
||||||
mod tests {}
|
type Error = CodecError;
|
||||||
|
|
||||||
|
fn csv_header(&self, _recording: &Recording<TactileAFrame>) -> Vec<String> {
|
||||||
|
let mut header: Vec<String> = 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<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")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let packet = TactileADataPacket::try_from(rep)?;
|
||||||
|
let mut row: Vec<String> = 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<TactileADataPacket> {
|
||||||
|
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::<i32>()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dts_cell = record
|
||||||
|
.get(self.channels)
|
||||||
|
.ok_or_else(|| anyhow!("missing dts cell"))?;
|
||||||
|
let dts_ms = dts_cell.parse::<u64>()?;
|
||||||
|
|
||||||
|
Ok(TactileADataPacket {
|
||||||
|
data: data,
|
||||||
|
dts_ms: dts_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CsvImporter<TactileADataPacket> for TactileACsvImporter {
|
||||||
|
fn load<R: Read>(&mut self, reader: R) -> anyhow::Result<Vec<TactileADataPacket>> {
|
||||||
|
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<W>(recording: &Recording<TactileAFrame>, 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use crate::serial_core::frame::{FrameHandler};
|
|||||||
use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame};
|
use crate::serial_core::{codec::Codec, error::CodecError, frame::TestFrame};
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Local;
|
|
||||||
use csv::StringRecord;
|
use csv::StringRecord;
|
||||||
use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
|
use crate::serial_core::record::{write_csv, CsvExporter, CsvImporter, RecordedFrame, Recording};
|
||||||
use crc::{Crc, CRC_8_SMBUS};
|
use crc::{Crc, CRC_8_SMBUS};
|
||||||
@@ -233,9 +232,7 @@ pub fn export_recording_csv<W>(recording: &Recording<TestFrame>, writer: W) -> a
|
|||||||
where
|
where
|
||||||
W: std::io::Write,
|
W: std::io::Write,
|
||||||
{
|
{
|
||||||
let now = Local::now();
|
write_csv(recording, &TestCsvExporter, writer)
|
||||||
let filename = format!("joyson_{}", now.format("%Y%m%d_%H%M%S"));
|
|
||||||
write_csv(recording, &TestCsvExporter, &filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -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 codec;
|
||||||
pub mod codecs;
|
pub mod codecs;
|
||||||
@@ -10,6 +13,7 @@ pub mod record;
|
|||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
pub type TestRecording = Recording<TestFrame>;
|
pub type TestRecording = Recording<TestFrame>;
|
||||||
|
pub type TactileARecording = Recording<TactileAFrame>;
|
||||||
|
|
||||||
pub struct SerialConnection {
|
pub struct SerialConnection {
|
||||||
pub port: String,
|
pub port: String,
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
use std::fs::{write, File};
|
|
||||||
use std::io;
|
|
||||||
use anyhow::{Result, anyhow};
|
|
||||||
use csv::Reader;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct FrameTiming {
|
pub struct FrameTiming {
|
||||||
pub pts_ms: Option<u64>,
|
pub pts_ms: Option<u64>,
|
||||||
@@ -38,20 +33,17 @@ pub trait CsvImporter<P> {
|
|||||||
fn load<R: std::io::Read>(&mut self, reader: R) -> anyhow::Result<Vec<P>>;
|
fn load<R: std::io::Read>(&mut self, reader: R) -> anyhow::Result<Vec<P>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_csv<F, E>(
|
pub fn write_csv<F, E, W>(
|
||||||
recording: &Recording<F>,
|
recording: &Recording<F>,
|
||||||
exporter: &E,
|
exporter: &E,
|
||||||
path: &str
|
writer: W,
|
||||||
// mut writer: W,
|
|
||||||
) -> anyhow::Result<()>
|
) -> anyhow::Result<()>
|
||||||
where
|
where
|
||||||
E: CsvExporter<F>,
|
E: CsvExporter<F>,
|
||||||
// W: std::io::Write
|
W: std::io::Write,
|
||||||
{
|
{
|
||||||
let header = exporter.csv_header(&recording);
|
let header = exporter.csv_header(&recording);
|
||||||
// let mut wrt = csv::Writer::from_writer(io::stdout());
|
let mut wrt = csv::Writer::from_writer(writer);
|
||||||
|
|
||||||
let mut wrt = csv::Writer::from_path(format!("{}.csv", path))?;
|
|
||||||
wrt.write_record(header)?;
|
wrt.write_record(header)?;
|
||||||
for f in &recording.frames {
|
for f in &recording.frames {
|
||||||
let row = exporter.csv_row(f)?;
|
let row = exporter.csv_row(f)?;
|
||||||
|
|||||||
@@ -54,10 +54,24 @@ impl SerialFrame for TactileAFrame {
|
|||||||
|
|
||||||
fn to_hud_packet(
|
fn to_hud_packet(
|
||||||
&self,
|
&self,
|
||||||
_chart_state: &mut HudChartState,
|
chart_state: &mut HudChartState,
|
||||||
_display_values: Option<&[i32]>,
|
display_values: Option<&[i32]>,
|
||||||
) -> Option<HudPacket> {
|
) -> Option<HudPacket> {
|
||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
833
src/lib/components/FileExplorerModal.svelte
Normal file
833
src/lib/components/FileExplorerModal.svelte
Normal file
@@ -0,0 +1,833 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onDestroy, onMount, tick } from "svelte";
|
||||||
|
import type { FileExplorerEntry, FileExplorerRoot } from "$lib/types/hud";
|
||||||
|
|
||||||
|
export let open = false;
|
||||||
|
export let mode: "open" | "save" = "open";
|
||||||
|
export let title = "";
|
||||||
|
export let currentPath = "";
|
||||||
|
export let parentPath: string | null = null;
|
||||||
|
export let roots: FileExplorerRoot[] = [];
|
||||||
|
export let entries: FileExplorerEntry[] = [];
|
||||||
|
export let selectedPath = "";
|
||||||
|
export let fileName = "";
|
||||||
|
export let pathLabel = "Path";
|
||||||
|
export let fileNameLabel = "File name";
|
||||||
|
export let cancelLabel = "Cancel";
|
||||||
|
export let confirmLabel = "Open";
|
||||||
|
export let emptyHint = "No file entries";
|
||||||
|
export let csvHint = "*.csv";
|
||||||
|
export let busyLabel = "Processing...";
|
||||||
|
export let upLabel = "↑ Up";
|
||||||
|
export let nameColumnLabel = "Name";
|
||||||
|
export let sizeColumnLabel = "Size";
|
||||||
|
export let modifiedColumnLabel = "Modified";
|
||||||
|
export let isBusy = false;
|
||||||
|
|
||||||
|
const dragViewportPadding = 14;
|
||||||
|
let overlayEl: HTMLDivElement | null = null;
|
||||||
|
let modalEl: HTMLDivElement | null = null;
|
||||||
|
let activePointerId: number | null = null;
|
||||||
|
let dragStartX = 0;
|
||||||
|
let dragStartY = 0;
|
||||||
|
let dragOriginX = 0;
|
||||||
|
let dragOriginY = 0;
|
||||||
|
let dragModalWidth = 0;
|
||||||
|
let dragModalHeight = 0;
|
||||||
|
let modalOffsetX = 0;
|
||||||
|
let modalOffsetY = 0;
|
||||||
|
let isDragging = false;
|
||||||
|
let wasOpen = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
close: void;
|
||||||
|
navigate: string;
|
||||||
|
confirm: void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
$: selectedEntry = entries.find((entry) => entry.path === selectedPath) ?? null;
|
||||||
|
$: canConfirm =
|
||||||
|
mode === "open"
|
||||||
|
? Boolean(selectedEntry && !selectedEntry.isDir && !isBusy)
|
||||||
|
: Boolean(fileName.trim().length > 0 && !isBusy);
|
||||||
|
$: if (open && !wasOpen) {
|
||||||
|
wasOpen = true;
|
||||||
|
modalOffsetX = 0;
|
||||||
|
modalOffsetY = 0;
|
||||||
|
stopDrag();
|
||||||
|
void tick().then(() => clampModalOffset());
|
||||||
|
}
|
||||||
|
$: if (!open && wasOpen) {
|
||||||
|
wasOpen = false;
|
||||||
|
stopDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(value: number | null | undefined): string {
|
||||||
|
if (!Number.isFinite(value ?? Number.NaN) || value == null) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 1024) {
|
||||||
|
return `${value} B`;
|
||||||
|
}
|
||||||
|
if (value < 1024 * 1024) {
|
||||||
|
return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModifiedTime(value: number | null | undefined): string {
|
||||||
|
if (!Number.isFinite(value ?? Number.NaN) || value == null) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Date(Number(value)).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(path: string): void {
|
||||||
|
if (!path || isBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch("navigate", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectEntry(entry: FileExplorerEntry): void {
|
||||||
|
selectedPath = entry.path;
|
||||||
|
if (mode === "save" && !entry.isDir) {
|
||||||
|
fileName = entry.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateEntry(entry: FileExplorerEntry): void {
|
||||||
|
if (entry.isDir) {
|
||||||
|
navigate(entry.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedPath = entry.path;
|
||||||
|
if (mode === "save") {
|
||||||
|
fileName = entry.name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canConfirm) {
|
||||||
|
dispatch("confirm");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(): void {
|
||||||
|
if (isBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSelection(): void {
|
||||||
|
if (!canConfirm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch("confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDragRange(modalSize: number, viewportSize: number): { min: number; max: number } {
|
||||||
|
const centeredGap = (viewportSize - modalSize) / 2;
|
||||||
|
const min = dragViewportPadding - centeredGap;
|
||||||
|
const max = centeredGap - dragViewportPadding;
|
||||||
|
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) {
|
||||||
|
return { min: 0, max: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampModalOffset(): void {
|
||||||
|
if (!open || !modalEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportWidth = overlayEl?.clientWidth ?? window.innerWidth;
|
||||||
|
const viewportHeight = overlayEl?.clientHeight ?? window.innerHeight;
|
||||||
|
const rect = modalEl.getBoundingClientRect();
|
||||||
|
const xRange = resolveDragRange(rect.width, viewportWidth);
|
||||||
|
const yRange = resolveDragRange(rect.height, viewportHeight);
|
||||||
|
|
||||||
|
modalOffsetX = clamp(modalOffsetX, xRange.min, xRange.max);
|
||||||
|
modalOffsetY = clamp(modalOffsetY, yRange.min, yRange.max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrag(): void {
|
||||||
|
activePointerId = null;
|
||||||
|
isDragging = false;
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", handlePointerUp);
|
||||||
|
window.removeEventListener("pointercancel", handlePointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent): void {
|
||||||
|
if (activePointerId == null || event.pointerId !== activePointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const viewportWidth = overlayEl?.clientWidth ?? window.innerWidth;
|
||||||
|
const viewportHeight = overlayEl?.clientHeight ?? window.innerHeight;
|
||||||
|
const xRange = resolveDragRange(dragModalWidth, viewportWidth);
|
||||||
|
const yRange = resolveDragRange(dragModalHeight, viewportHeight);
|
||||||
|
const rawX = dragOriginX + (event.clientX - dragStartX);
|
||||||
|
const rawY = dragOriginY + (event.clientY - dragStartY);
|
||||||
|
|
||||||
|
modalOffsetX = clamp(rawX, xRange.min, xRange.max);
|
||||||
|
modalOffsetY = clamp(rawY, yRange.min, yRange.max);
|
||||||
|
isDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent): void {
|
||||||
|
if (activePointerId == null || event.pointerId !== activePointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(event: PointerEvent): void {
|
||||||
|
if (event.button !== 0 || !event.isPrimary || !modalEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target instanceof HTMLElement && event.target.closest("button, input, select, textarea, a")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = modalEl.getBoundingClientRect();
|
||||||
|
dragModalWidth = rect.width;
|
||||||
|
dragModalHeight = rect.height;
|
||||||
|
dragStartX = event.clientX;
|
||||||
|
dragStartY = event.clientY;
|
||||||
|
dragOriginX = modalOffsetX;
|
||||||
|
dragOriginY = modalOffsetY;
|
||||||
|
activePointerId = event.pointerId;
|
||||||
|
isDragging = true;
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", handlePointerMove, { passive: false });
|
||||||
|
window.addEventListener("pointerup", handlePointerUp);
|
||||||
|
window.addEventListener("pointercancel", handlePointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleViewportResize(): void {
|
||||||
|
clampModalOffset();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener("resize", handleViewportResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleViewportResize);
|
||||||
|
stopDrag();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
stopDrag();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
bind:this={overlayEl}
|
||||||
|
class="explorer-overlay"
|
||||||
|
role="presentation"
|
||||||
|
on:click={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
bind:this={modalEl}
|
||||||
|
class="explorer-modal"
|
||||||
|
class:is-dragging={isDragging}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
tabindex="-1"
|
||||||
|
style={`--explorer-drag-x: ${modalOffsetX}px; --explorer-drag-y: ${modalOffsetY}px;`}
|
||||||
|
on:keydown={(event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="explorer-header"
|
||||||
|
role="toolbar"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="Dialog drag bar"
|
||||||
|
on:pointerdown={startDrag}
|
||||||
|
>
|
||||||
|
<div class="explorer-title-wrap">
|
||||||
|
<span class="title-pulse" aria-hidden="true"></span>
|
||||||
|
<h3 class="explorer-title">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="header-close-btn" aria-label="Close" on:click={closeModal}>×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="explorer-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-btn"
|
||||||
|
disabled={!parentPath || isBusy}
|
||||||
|
on:click={() => parentPath && navigate(parentPath)}
|
||||||
|
>
|
||||||
|
{upLabel}
|
||||||
|
</button>
|
||||||
|
<div class="path-field" title={currentPath}>
|
||||||
|
<span class="path-label">{pathLabel}</span>
|
||||||
|
<span class="path-value">{currentPath}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="explorer-content">
|
||||||
|
<aside class="roots-list" aria-label="Roots">
|
||||||
|
{#each roots as root (root.path)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="root-btn"
|
||||||
|
class:is-active={currentPath === root.path}
|
||||||
|
on:click={() => navigate(root.path)}
|
||||||
|
>
|
||||||
|
{root.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="entries-wrap" aria-label="Entries">
|
||||||
|
<div class="entries-head">
|
||||||
|
<span>{nameColumnLabel}</span>
|
||||||
|
<span>{sizeColumnLabel}</span>
|
||||||
|
<span>{modifiedColumnLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if entries.length === 0}
|
||||||
|
<p class="entries-empty">{emptyHint}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="entries-body">
|
||||||
|
{#each entries as entry (entry.path)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="entry-row"
|
||||||
|
class:is-selected={entry.path === selectedPath}
|
||||||
|
on:click={() => selectEntry(entry)}
|
||||||
|
on:dblclick={() => activateEntry(entry)}
|
||||||
|
>
|
||||||
|
<span class="entry-name">
|
||||||
|
<span class="entry-icon" aria-hidden="true">{entry.isDir ? "DIR" : "CSV"}</span>
|
||||||
|
<span class="entry-text">{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<span class="entry-size">{entry.isDir ? "--" : formatFileSize(entry.sizeBytes)}</span>
|
||||||
|
<span class="entry-time">{formatModifiedTime(entry.modifiedMs)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="explorer-footer">
|
||||||
|
{#if mode === "save"}
|
||||||
|
<label class="name-input-wrap">
|
||||||
|
<span class="name-label">{fileNameLabel}</span>
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={fileName}
|
||||||
|
placeholder="joyson_export.csv"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<p class="csv-hint">{csvHint}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button type="button" class="action-btn cancel" disabled={isBusy} on:click={closeModal}>{cancelLabel}</button>
|
||||||
|
<button type="button" class="action-btn confirm" disabled={!canConfirm} on:click={confirmSelection}>
|
||||||
|
{isBusy ? busyLabel : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.explorer-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 12%, rgb(62 232 255 / 0.08), transparent 42%),
|
||||||
|
radial-gradient(circle at 84% 10%, rgb(133 255 68 / 0.07), transparent 40%),
|
||||||
|
rgb(0 0 0 / 0.6);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
padding: clamp(0.65rem, 2.4vw, 1.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-modal {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
inline-size: min(1020px, 100%);
|
||||||
|
block-size: min(720px, 100%);
|
||||||
|
max-inline-size: calc(100% - 2 * clamp(0.65rem, 2.4vw, 1.25rem));
|
||||||
|
max-block-size: calc(100% - 2 * clamp(0.65rem, 2.4vw, 1.25rem));
|
||||||
|
border: 1px solid rgb(95 132 158 / 0.34);
|
||||||
|
border-radius: 0.72rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(172deg, rgb(8 12 16 / 0.96) 0%, rgb(4 8 12 / 0.96) 52%, rgb(3 6 10 / 0.98) 100%),
|
||||||
|
radial-gradient(circle at 18% 0, rgb(62 232 255 / 0.06), transparent 42%),
|
||||||
|
radial-gradient(circle at 90% 0, rgb(133 255 68 / 0.05), transparent 38%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgb(192 221 240 / 0.08),
|
||||||
|
0 22px 50px rgb(0 0 0 / 0.52);
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translate3d(var(--explorer-drag-x, 0), var(--explorer-drag-y, 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.72rem 0.85rem 0.65rem;
|
||||||
|
border-bottom: 1px solid rgb(95 132 158 / 0.28);
|
||||||
|
background: linear-gradient(180deg, rgb(16 25 32 / 0.6), transparent);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-modal.is-dragging .explorer-header {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-title-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-pulse {
|
||||||
|
inline-size: 0.5rem;
|
||||||
|
block-size: 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--hud-lime);
|
||||||
|
box-shadow: 0 0 0 2px rgb(133 255 68 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-title {
|
||||||
|
margin: 0;
|
||||||
|
color: #ecf9ff;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-close-btn {
|
||||||
|
inline-size: 2rem;
|
||||||
|
block-size: 1.64rem;
|
||||||
|
border: 1px solid rgb(98 131 156 / 0.36);
|
||||||
|
border-radius: 0.36rem;
|
||||||
|
background: rgb(8 13 18 / 0.9);
|
||||||
|
color: rgb(174 219 244 / 0.9);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 180ms ease,
|
||||||
|
color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-close-btn:hover {
|
||||||
|
border-color: rgb(255 91 63 / 0.6);
|
||||||
|
color: rgb(255 208 198 / 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.58rem;
|
||||||
|
padding: 0.62rem 0.85rem;
|
||||||
|
border-bottom: 1px solid rgb(95 132 158 / 0.22);
|
||||||
|
background: linear-gradient(90deg, rgb(62 232 255 / 0.03), transparent 44%, rgb(133 255 68 / 0.02));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
min-block-size: 1.95rem;
|
||||||
|
border: 1px solid rgb(95 132 158 / 0.36);
|
||||||
|
border-radius: 0.42rem;
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
background: rgb(9 16 21 / 0.86);
|
||||||
|
color: rgb(213 233 245 / 0.94);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 180ms ease,
|
||||||
|
box-shadow 180ms ease,
|
||||||
|
opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover:not(:disabled) {
|
||||||
|
border-color: rgb(62 232 255 / 0.46);
|
||||||
|
box-shadow: inset 0 0 0 1px rgb(178 216 239 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:disabled {
|
||||||
|
opacity: 0.58;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-field {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border: 1px solid rgb(95 132 158 / 0.32);
|
||||||
|
border-radius: 0.42rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
background: rgb(8 14 18 / 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-label {
|
||||||
|
color: rgb(140 163 181 / 0.84);
|
||||||
|
font-size: 0.63rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-value {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: rgb(225 243 255 / 0.97);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-content {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px minmax(0, 1fr);
|
||||||
|
gap: 0.62rem;
|
||||||
|
padding: 0.72rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roots-list {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-auto-rows: min-content;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 1px solid rgb(95 132 158 / 0.28);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.42rem;
|
||||||
|
background: rgb(7 13 18 / 0.78);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-btn {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.34rem;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
color: rgb(167 189 208 / 0.94);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 180ms ease,
|
||||||
|
background-color 180ms ease,
|
||||||
|
color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-btn:hover {
|
||||||
|
border-color: rgb(62 232 255 / 0.3);
|
||||||
|
color: #e5f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root-btn.is-active {
|
||||||
|
border-color: rgb(133 255 68 / 0.46);
|
||||||
|
background: rgb(24 33 22 / 0.7);
|
||||||
|
color: rgb(237 255 228 / 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-wrap {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
border: 1px solid rgb(95 132 158 / 0.28);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgb(6 11 16 / 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 110px 180px;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.44rem 0.55rem;
|
||||||
|
border-bottom: 1px solid rgb(95 132 158 / 0.24);
|
||||||
|
color: rgb(141 164 183 / 0.88);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-empty {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: rgb(148 171 187 / 0.86);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.18rem;
|
||||||
|
display: grid;
|
||||||
|
grid-auto-rows: min-content;
|
||||||
|
gap: 0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 110px 180px;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.32rem;
|
||||||
|
padding: 0.32rem 0.4rem;
|
||||||
|
background: transparent;
|
||||||
|
color: rgb(204 227 243 / 0.94);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-row:hover {
|
||||||
|
border-color: rgb(62 232 255 / 0.26);
|
||||||
|
background: rgb(11 18 24 / 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-row.is-selected {
|
||||||
|
border-color: rgb(133 255 68 / 0.46);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgb(24 33 22 / 0.86), rgb(14 21 14 / 0.78)),
|
||||||
|
radial-gradient(circle at 6% 50%, rgb(133 255 68 / 0.15), transparent 58%);
|
||||||
|
box-shadow: inset 0 0 0 1px rgb(230 255 220 / 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-name {
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-icon {
|
||||||
|
inline-size: 2.15rem;
|
||||||
|
block-size: 1.2rem;
|
||||||
|
border: 1px solid rgb(95 132 158 / 0.36);
|
||||||
|
border-radius: 0.22rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: rgb(150 177 198 / 0.9);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
background: rgb(9 16 22 / 0.72);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-row.is-selected .entry-icon {
|
||||||
|
border-color: rgb(133 255 68 / 0.44);
|
||||||
|
color: rgb(214 252 190 / 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-size,
|
||||||
|
.entry-time {
|
||||||
|
color: rgb(152 176 194 / 0.88);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.7rem;
|
||||||
|
border-top: 1px solid rgb(95 132 158 / 0.24);
|
||||||
|
padding: 0.68rem 0.85rem 0.76rem;
|
||||||
|
background: linear-gradient(0deg, rgb(5 10 14 / 0.72), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-label {
|
||||||
|
color: rgb(140 163 181 / 0.86);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input {
|
||||||
|
min-inline-size: 0;
|
||||||
|
border: 1px solid rgb(95 132 158 / 0.36);
|
||||||
|
border-radius: 0.36rem;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
background: rgb(8 14 19 / 0.8);
|
||||||
|
color: rgb(223 242 255 / 0.97);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 170ms ease,
|
||||||
|
box-shadow 170ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input:focus-visible {
|
||||||
|
border-color: rgb(62 232 255 / 0.52);
|
||||||
|
box-shadow: 0 0 0 2px rgb(62 232 255 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: rgb(150 173 189 / 0.9);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
min-block-size: 2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.28rem 0.78rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 180ms ease,
|
||||||
|
opacity 160ms ease,
|
||||||
|
box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.cancel {
|
||||||
|
border: 1px solid rgb(95 132 158 / 0.36);
|
||||||
|
background: rgb(9 16 21 / 0.86);
|
||||||
|
color: rgb(206 228 244 / 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.cancel:hover:not(:disabled) {
|
||||||
|
border-color: rgb(122 198 255 / 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.confirm {
|
||||||
|
border: 1px solid rgb(133 255 68 / 0.48);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgb(25 35 23 / 0.96), rgb(13 20 13 / 0.92)),
|
||||||
|
radial-gradient(circle at 50% 0, rgb(133 255 68 / 0.14), transparent 58%);
|
||||||
|
color: rgb(240 255 233 / 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.confirm:hover:not(:disabled) {
|
||||||
|
border-color: rgb(176 255 132 / 0.62);
|
||||||
|
box-shadow: 0 0 10px rgb(133 255 68 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.explorer-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 140px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roots-list {
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(120px, 1fr);
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.explorer-modal {
|
||||||
|
block-size: min(760px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-head,
|
||||||
|
.entry-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-head span:last-child,
|
||||||
|
.entry-time {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -41,7 +41,6 @@
|
|||||||
export let isExporting = false;
|
export let isExporting = false;
|
||||||
export let isExportDisabled = false;
|
export let isExportDisabled = false;
|
||||||
export let isWindowMaximized = false;
|
export let isWindowMaximized = false;
|
||||||
let csvInputEl: HTMLInputElement | undefined;
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
windowcontrol: WindowControlAction;
|
windowcontrol: WindowControlAction;
|
||||||
@@ -51,7 +50,8 @@
|
|||||||
serialrefresh: void;
|
serialrefresh: void;
|
||||||
serialconnect: string;
|
serialconnect: string;
|
||||||
serialexport: void;
|
serialexport: void;
|
||||||
csvimport: File;
|
csvimport: void;
|
||||||
|
noticeclear: void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const connectionToneByState: Record<ConnectionState, "ok" | "warn" | "idle"> = {
|
const connectionToneByState: Record<ConnectionState, "ok" | "warn" | "idle"> = {
|
||||||
@@ -106,17 +106,12 @@
|
|||||||
dispatch("serialexport");
|
dispatch("serialexport");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCsvPicker(): void {
|
function emitCsvImport(): void {
|
||||||
csvInputEl?.click();
|
dispatch("csvimport");
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitCsvImport(event: Event): void {
|
function emitNoticeClear(): void {
|
||||||
const target = event.currentTarget as HTMLInputElement;
|
dispatch("noticeclear");
|
||||||
const file = target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
dispatch("csvimport", file);
|
|
||||||
}
|
|
||||||
target.value = "";
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -246,7 +241,7 @@
|
|||||||
<span>{exportButtonText}</span>
|
<span>{exportButtonText}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" class="import-btn" on:click={openCsvPicker}>
|
<button type="button" class="import-btn" on:click={emitCsvImport}>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<path d="M8 10.8V3.6"></path>
|
<path d="M8 10.8V3.6"></path>
|
||||||
<path d="M5.4 6.3L8 3.6l2.6 2.7"></path>
|
<path d="M5.4 6.3L8 3.6l2.6 2.7"></path>
|
||||||
@@ -254,13 +249,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{importActionLabel}</span>
|
<span>{importActionLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
bind:this={csvInputEl}
|
|
||||||
class="hidden-input"
|
|
||||||
type="file"
|
|
||||||
accept=".csv,text/csv"
|
|
||||||
on:change={emitCsvImport}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class="locale-switch" aria-label="Language">
|
<section class="locale-switch" aria-label="Language">
|
||||||
<button
|
<button
|
||||||
@@ -283,9 +271,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if connectionNotice}
|
{#if connectionNotice}
|
||||||
<p class="connection-notice tone-{connectionNoticeTone}" role={connectionNoticeTone === "warn" ? "alert" : "status"}>
|
<div class="connection-notice tone-{connectionNoticeTone}" role={connectionNoticeTone === "warn" ? "alert" : "status"}>
|
||||||
{connectionNotice}
|
<p class="connection-notice-text">{connectionNotice}</p>
|
||||||
</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="notice-close-btn"
|
||||||
|
aria-label={locale === "zh-CN" ? "关闭提示" : "Dismiss notice"}
|
||||||
|
on:click={emitNoticeClear}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="info-grid">
|
<section class="info-grid">
|
||||||
@@ -724,20 +720,22 @@
|
|||||||
0 0 12px rgb(122 198 255 / 0.14);
|
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 {
|
.connection-notice {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
border: 1px solid rgb(95 132 158 / 0.32);
|
border: 1px solid rgb(95 132 158 / 0.32);
|
||||||
border-radius: 0.5rem;
|
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);
|
background: rgb(8 14 19 / 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-notice-text {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
color: rgb(214 236 248 / 0.96);
|
color: rgb(214 236 248 / 0.96);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
@@ -758,7 +756,41 @@
|
|||||||
.connection-notice.tone-info {
|
.connection-notice.tone-info {
|
||||||
border-color: rgb(62 232 255 / 0.34);
|
border-color: rgb(62 232 255 / 0.34);
|
||||||
background: rgb(8 17 22 / 0.76);
|
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 {
|
.info-grid {
|
||||||
|
|||||||
@@ -49,8 +49,8 @@
|
|||||||
const MAX_LABEL_SCALE = 2.45;
|
const MAX_LABEL_SCALE = 2.45;
|
||||||
const MATRIX_OFFSET_Y = -2.4;
|
const MATRIX_OFFSET_Y = -2.4;
|
||||||
const MATRIX_OFFSET_Z = 12;
|
const MATRIX_OFFSET_Z = 12;
|
||||||
const HEIGHT_SCALE = 18.5;
|
const HEIGHT_SCALE = 10.6;
|
||||||
const BASE_HEIGHT = 0.18;
|
const BASE_HEIGHT = 0.12;
|
||||||
const GLOW_START = 0.3;
|
const GLOW_START = 0.3;
|
||||||
const SMOOTHING_SPEED = 8.2;
|
const SMOOTHING_SPEED = 8.2;
|
||||||
const CAMERA_FOV = 36;
|
const CAMERA_FOV = 36;
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shapeHeightValue(valueNormalized: number): number {
|
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 {
|
function shapeGlowStrength(valueNormalized: number): number {
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
const gridSpan = Math.max(boardWidth, boardDepth) + boardPadding * 2;
|
const gridSpan = Math.max(boardWidth, boardDepth) + boardPadding * 2;
|
||||||
const gridDivisions = Math.round(clamp(longestEdge * 1.6, MIN_GRID_DIVISIONS, MAX_GRID_DIVISIONS));
|
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 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 {
|
return {
|
||||||
cellSpacing,
|
cellSpacing,
|
||||||
|
|||||||
@@ -99,6 +99,20 @@ export interface HudCopy {
|
|||||||
exportActionLabel: string;
|
exportActionLabel: string;
|
||||||
exportingActionLabel: string;
|
exportingActionLabel: string;
|
||||||
importActionLabel: 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;
|
replaySectionLabel: string;
|
||||||
replayPlayLabel: string;
|
replayPlayLabel: string;
|
||||||
replayPauseLabel: string;
|
replayPauseLabel: string;
|
||||||
@@ -131,6 +145,11 @@ export interface SerialExportResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SerialRecordStateResult {
|
||||||
|
hasData: boolean;
|
||||||
|
frameCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SerialImportFrameResult {
|
export interface SerialImportFrameResult {
|
||||||
data: number[];
|
data: number[];
|
||||||
dtsMs: number;
|
dtsMs: number;
|
||||||
@@ -143,3 +162,23 @@ export interface SerialImportResult {
|
|||||||
frames: SerialImportFrameResult[];
|
frames: SerialImportFrameResult[];
|
||||||
message: string;
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@
|
|||||||
import { LogicalSize, currentMonitor, getCurrentWindow } from "@tauri-apps/api/window";
|
import { LogicalSize, currentMonitor, getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import HudPanel from "$lib/components/HudPanel.svelte";
|
import HudPanel from "$lib/components/HudPanel.svelte";
|
||||||
import CenterStage from "$lib/components/CenterStage.svelte";
|
import CenterStage from "$lib/components/CenterStage.svelte";
|
||||||
|
import FileExplorerModal from "$lib/components/FileExplorerModal.svelte";
|
||||||
import { pressureColorPalettes } from "$lib/config/color-map";
|
import { pressureColorPalettes } from "$lib/config/color-map";
|
||||||
import "$lib/styles/theme.css";
|
import "$lib/styles/theme.css";
|
||||||
import type {
|
import type {
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
|
FileExplorerEntry,
|
||||||
|
FileExplorerListResult,
|
||||||
|
FileExplorerRoot,
|
||||||
HudColorMapOption,
|
HudColorMapOption,
|
||||||
HudCopy,
|
HudCopy,
|
||||||
HudConfigLink,
|
HudConfigLink,
|
||||||
@@ -21,6 +25,7 @@
|
|||||||
LocaleCode,
|
LocaleCode,
|
||||||
SerialConnectResult,
|
SerialConnectResult,
|
||||||
SerialExportResult,
|
SerialExportResult,
|
||||||
|
SerialRecordStateResult,
|
||||||
SerialImportResult,
|
SerialImportResult,
|
||||||
SignalTone,
|
SignalTone,
|
||||||
StageStatusTone,
|
StageStatusTone,
|
||||||
@@ -28,6 +33,8 @@
|
|||||||
} from "$lib/types/hud";
|
} from "$lib/types/hud";
|
||||||
|
|
||||||
type SignalPanelTemplate = Pick<HudSignalPanel, "id" | "code" | "title" | "side">;
|
type SignalPanelTemplate = Pick<HudSignalPanel, "id" | "code" | "title" | "side">;
|
||||||
|
type FileExplorerMode = "open" | "save";
|
||||||
|
|
||||||
interface ReplayFrame {
|
interface ReplayFrame {
|
||||||
values: number[];
|
values: number[];
|
||||||
dtsMs: number;
|
dtsMs: number;
|
||||||
@@ -65,6 +72,20 @@
|
|||||||
exportActionLabel: "导出 CSV",
|
exportActionLabel: "导出 CSV",
|
||||||
exportingActionLabel: "导出中",
|
exportingActionLabel: "导出中",
|
||||||
importActionLabel: "导入 CSV",
|
importActionLabel: "导入 CSV",
|
||||||
|
fileExplorerImportTitle: "导入 CSV 文件",
|
||||||
|
fileExplorerExportTitle: "导出 CSV 文件",
|
||||||
|
fileExplorerPathLabel: "路径",
|
||||||
|
fileExplorerNameLabel: "文件名",
|
||||||
|
fileExplorerCancelLabel: "取消",
|
||||||
|
fileExplorerOpenLabel: "打开",
|
||||||
|
fileExplorerSaveLabel: "保存",
|
||||||
|
fileExplorerEmptyHint: "当前目录下没有可用条目",
|
||||||
|
fileExplorerCsvHint: "仅显示 *.csv 文件",
|
||||||
|
fileExplorerLoadingLabel: "处理中...",
|
||||||
|
fileExplorerUpLabel: "↑ 上一级",
|
||||||
|
fileExplorerNameColumnLabel: "名称",
|
||||||
|
fileExplorerSizeColumnLabel: "大小",
|
||||||
|
fileExplorerModifiedColumnLabel: "修改时间",
|
||||||
replaySectionLabel: "回放",
|
replaySectionLabel: "回放",
|
||||||
replayPlayLabel: "播放",
|
replayPlayLabel: "播放",
|
||||||
replayPauseLabel: "暂停",
|
replayPauseLabel: "暂停",
|
||||||
@@ -107,6 +128,20 @@
|
|||||||
exportActionLabel: "Export CSV",
|
exportActionLabel: "Export CSV",
|
||||||
exportingActionLabel: "Exporting",
|
exportingActionLabel: "Exporting",
|
||||||
importActionLabel: "Import CSV",
|
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",
|
replaySectionLabel: "Replay",
|
||||||
replayPlayLabel: "Play",
|
replayPlayLabel: "Play",
|
||||||
replayPauseLabel: "Pause",
|
replayPauseLabel: "Pause",
|
||||||
@@ -186,6 +221,15 @@
|
|||||||
let replayProgress = 0;
|
let replayProgress = 0;
|
||||||
let replayFileName = "";
|
let replayFileName = "";
|
||||||
let replayTimerId: number | null = null;
|
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];
|
$: uiCopy = copyByLocale[locale];
|
||||||
$: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen);
|
$: configLinks = buildConfigLinks(locale, activeConfigLinkId, isConfigPanelOpen);
|
||||||
@@ -197,6 +241,10 @@
|
|||||||
$: rangeScaleStyle = buildRangeScaleStyle(colorMapPreset);
|
$: rangeScaleStyle = buildRangeScaleStyle(colorMapPreset);
|
||||||
$: replayHasData = replayFrames.length > 0;
|
$: replayHasData = replayFrames.length > 0;
|
||||||
$: replayFrameInfo = replayHasData ? `${replayHasDisplayedFrame ? replayCurrentIndex + 1 : 0}/${replayFrames.length}` : "";
|
$: 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 {
|
function isTauriRuntime(): boolean {
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
@@ -353,6 +401,209 @@
|
|||||||
return frames;
|
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<void> {
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileExplorerBusy = true;
|
||||||
|
try {
|
||||||
|
const result = await invoke<FileExplorerListResult>("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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = ".csv,text/csv";
|
||||||
|
|
||||||
|
const selectedFile = await new Promise<File | null>((resolve) => {
|
||||||
|
input.onchange = () => resolve(input.files?.[0] ?? null);
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await importReplayFromFile(selectedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importReplayFromFile(file: File): Promise<boolean> {
|
||||||
|
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<SerialImportResult>("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<boolean> {
|
||||||
|
pauseReplayPlayback();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<SerialImportResult>("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 {
|
function stopReplayTimer(): void {
|
||||||
if (replayTimerId == null || typeof window === "undefined") {
|
if (replayTimerId == null || typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
@@ -980,81 +1231,118 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSerialExport(): Promise<void> {
|
async function runSerialExport(filePath?: string): Promise<boolean> {
|
||||||
if (!isTauriRuntime()) {
|
if (!isTauriRuntime()) {
|
||||||
console.warn("[serial] Export is only available inside Tauri.");
|
connectionNotice =
|
||||||
return;
|
locale === "zh-CN" ? "当前环境不支持导出到本地路径。" : "Current runtime cannot export to local paths.";
|
||||||
|
connectionNoticeTone = "warn";
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
isExporting = true;
|
isExporting = true;
|
||||||
|
fileExplorerBusy = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await invoke<SerialExportResult>("serial_export_csv");
|
const result = filePath
|
||||||
|
? await invoke<SerialExportResult>("serial_export_csv_to_path", { filePath })
|
||||||
|
: await invoke<SerialExportResult>("serial_export_csv");
|
||||||
|
|
||||||
connectionNotice =
|
connectionNotice =
|
||||||
locale === "zh-CN"
|
locale === "zh-CN"
|
||||||
? `CSV 导出成功(${result.frameCount} 帧):${result.path}`
|
? `CSV 导出成功(${result.frameCount} 帧):${result.path}`
|
||||||
: `CSV exported (${result.frameCount} frames): ${result.path}`;
|
: `CSV exported (${result.frameCount} frames): ${result.path}`;
|
||||||
connectionNoticeTone = "ok";
|
connectionNoticeTone = "ok";
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
connectionNotice = resolveExportNotice(error);
|
connectionNotice = resolveExportNotice(error);
|
||||||
connectionNoticeTone = "warn";
|
connectionNoticeTone = "warn";
|
||||||
console.error("Serial export failed:", error);
|
console.error("Serial export failed:", error);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
isExporting = false;
|
isExporting = false;
|
||||||
|
fileExplorerBusy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReplayImport(event: CustomEvent<File>): Promise<void> {
|
async function precheckExportRecordData(): Promise<boolean> {
|
||||||
const file = event.detail;
|
if (!isTauriRuntime()) {
|
||||||
if (!file) {
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<SerialRecordStateResult>("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<void> {
|
||||||
|
const hasData = await precheckExportRecordData();
|
||||||
|
if (!hasData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pauseReplayPlayback();
|
await openFileExplorer("save");
|
||||||
|
|
||||||
try {
|
|
||||||
const text = await file.text();
|
|
||||||
let frames: ReplayFrame[];
|
|
||||||
let importedFrameCount = 0;
|
|
||||||
let importedChannelCount = 0;
|
|
||||||
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
const result = await invoke<SerialImportResult>("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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!frames.length) {
|
async function handleReplayImportRequest(): Promise<void> {
|
||||||
throw new Error("EmptyReplayData");
|
await openFileExplorer("open");
|
||||||
}
|
}
|
||||||
|
|
||||||
replayFrames = frames;
|
async function handleFileExplorerNavigate(event: CustomEvent<string>): Promise<void> {
|
||||||
replayFileName = file.name;
|
await loadFileExplorerDirectory(event.detail);
|
||||||
replayCurrentIndex = 0;
|
}
|
||||||
replayHasDisplayedFrame = false;
|
|
||||||
replayProgress = 0;
|
|
||||||
resetReplayVisualState();
|
|
||||||
|
|
||||||
connectionNotice =
|
async function handleFileExplorerConfirm(): Promise<void> {
|
||||||
locale === "zh-CN"
|
if (fileExplorerBusy) {
|
||||||
? `已导入 ${file.name},共 ${importedFrameCount} 帧 / ${importedChannelCount} 通道,可开始回放。`
|
return;
|
||||||
: `${file.name} loaded (${importedFrameCount} frames / ${importedChannelCount} channels). Ready to replay.`;
|
}
|
||||||
connectionNoticeTone = "ok";
|
|
||||||
} catch (error) {
|
if (fileExplorerMode === "open") {
|
||||||
connectionNotice = resolveImportNotice(error);
|
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";
|
connectionNoticeTone = "warn";
|
||||||
console.error("Replay import failed:", error);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileExplorerBusy = true;
|
||||||
|
const ok = await importReplayFromPath(selected.path);
|
||||||
|
fileExplorerBusy = false;
|
||||||
|
if (ok) {
|
||||||
|
fileExplorerOpen = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = fileExplorerEntries.find((entry) => entry.path === fileExplorerSelectedPath);
|
||||||
|
const targetDir = selected?.isDir ? selected.path : fileExplorerCurrentPath;
|
||||||
|
const csvName = ensureCsvSuffix(fileExplorerFileName || buildDefaultExportName());
|
||||||
|
if (!csvName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = joinPath(targetDir, csvName);
|
||||||
|
const ok = await runSerialExport(targetPath);
|
||||||
|
if (ok) {
|
||||||
|
fileExplorerOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,8 +1516,9 @@
|
|||||||
on:configlink={handleConfigLink}
|
on:configlink={handleConfigLink}
|
||||||
on:serialrefresh={handleSerialRefresh}
|
on:serialrefresh={handleSerialRefresh}
|
||||||
on:serialconnect={handleSerialConnect}
|
on:serialconnect={handleSerialConnect}
|
||||||
on:serialexport={handleSerialExport}
|
on:serialexport={handleSerialExportRequest}
|
||||||
on:csvimport={handleReplayImport}
|
on:csvimport={handleReplayImportRequest}
|
||||||
|
on:noticeclear={() => (connectionNotice = "")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CenterStage
|
<CenterStage
|
||||||
@@ -1288,6 +1577,33 @@
|
|||||||
</section>
|
</section>
|
||||||
</CenterStage>
|
</CenterStage>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FileExplorerModal
|
||||||
|
open={fileExplorerOpen}
|
||||||
|
mode={fileExplorerMode}
|
||||||
|
title={fileExplorerTitle}
|
||||||
|
currentPath={fileExplorerCurrentPath}
|
||||||
|
parentPath={fileExplorerParentPath}
|
||||||
|
roots={fileExplorerRoots}
|
||||||
|
entries={fileExplorerEntries}
|
||||||
|
bind:selectedPath={fileExplorerSelectedPath}
|
||||||
|
bind:fileName={fileExplorerFileName}
|
||||||
|
pathLabel={uiCopy.fileExplorerPathLabel}
|
||||||
|
fileNameLabel={uiCopy.fileExplorerNameLabel}
|
||||||
|
cancelLabel={uiCopy.fileExplorerCancelLabel}
|
||||||
|
confirmLabel={fileExplorerConfirmLabel}
|
||||||
|
emptyHint={uiCopy.fileExplorerEmptyHint}
|
||||||
|
csvHint={uiCopy.fileExplorerCsvHint}
|
||||||
|
busyLabel={uiCopy.fileExplorerLoadingLabel}
|
||||||
|
upLabel={uiCopy.fileExplorerUpLabel}
|
||||||
|
nameColumnLabel={uiCopy.fileExplorerNameColumnLabel}
|
||||||
|
sizeColumnLabel={uiCopy.fileExplorerSizeColumnLabel}
|
||||||
|
modifiedColumnLabel={uiCopy.fileExplorerModifiedColumnLabel}
|
||||||
|
isBusy={fileExplorerBusy}
|
||||||
|
on:close={closeFileExplorer}
|
||||||
|
on:navigate={handleFileExplorerNavigate}
|
||||||
|
on:confirm={handleFileExplorerConfirm}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in New Issue
Block a user