From 174adb536659a1e342f0524e741324b92ff85f98 Mon Sep 17 00:00:00 2001 From: yanjie Date: Wed, 20 May 2026 17:27:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20JE-Skin=20=E5=8A=9F=E8=83=BD=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=20=E2=80=94=20One=20Dark=20Pro=20=E5=B7=A5=E4=B8=9A?= =?UTF-8?q?=E9=A3=8E=20+=20=E5=BD=95=E5=88=B6=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## One Dark Pro 主题 - 替换 ENGINEERING_DARK → ONE_DARK_PRO (#282C34 背景, #C678DD 紫色强调) - 新增 ACCENT_GREEN/RED/BLUE/CYAN/ORANGE 独立色彩常量 ## 录制模块 (recording.rs — 新建) - 全量录制 + 快照录制两种模式 - 暂停/恢复/停止状态管理 - CSV 导出 (channel1,...,channelN,timestamp_ms) - CSV 导入回放 - 线程安全 Arc> - 4 个单元测试 ## 新 UI 组件 (ui.rs — +343 行) - draw_signal_chart: 实时信号火花图 (min/max/current) - draw_recording_toolbar: 录制控制栏 (全量/快照/暂停/导出/导入) - draw_export_panel: 浮动录制导出面板 - draw_matrix_config_panel: 矩阵配置 (行/列/色域/预设 12x7~64x32) - 连接面板集成录制工具栏 (连接后自动显示) ## 应用集成 (app.rs) - 集成 Recorder, 信号历史, 导出面板, 矩阵配置面板 - 每帧数据自动送入录制器 - 信号历史环形缓冲 (128 帧) --- src/app.rs | 42 ++++- src/main.rs | 1 + src/recording.rs | 441 +++++++++++++++++++++++++++++++++++++++++++++++ src/theme.rs | 55 +++--- src/ui.rs | 343 ++++++++++++++++++++++++++++++++---- 5 files changed, 823 insertions(+), 59 deletions(-) create mode 100644 src/recording.rs diff --git a/src/app.rs b/src/app.rs index 3d45daf..770bfeb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,15 +2,17 @@ use eframe::{egui, egui_wgpu}; use std::sync::Arc; use crate::connection::ConnectionManager; -use crate::theme::{ENGINEERING_DARK, apply_fonts, apply_theme}; +use crate::recording::Recorder; +use crate::theme::{ONE_DARK_PRO, apply_fonts, apply_theme}; use crate::{ matrix::{MATRIX_COLS, MATRIX_ROWS}, render::{ BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback, }, ui::{ - ConfigPanelState, ConnectPanelState, FloatingPanelState, draw_config_panel, - draw_connect_panel, draw_scene_panel, draw_stats_panel, + ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState, + draw_config_panel, draw_connect_panel, draw_export_panel, draw_matrix_config_panel, + draw_scene_panel, draw_signal_chart, draw_stats_panel, draw_recording_toolbar, }, }; @@ -26,13 +28,20 @@ pub struct EskinDesktopApp { config_panel: FloatingPanelState, config_state: ConfigPanelState, stats_panel: FloatingPanelState, + // New: recording, export, matrix config, signal charts + recorder: Recorder, + export_panel: FloatingPanelState, + export_path: String, + matrix_config_panel: FloatingPanelState, + matrix_config: MatrixConfigState, + signal_history: Vec, } impl EskinDesktopApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { egui_extras::install_image_loaders(&cc.egui_ctx); apply_fonts(&cc.egui_ctx); - apply_theme(&cc.egui_ctx, &ENGINEERING_DARK); + apply_theme(&cc.egui_ctx, &ONE_DARK_PRO); let wgpu_state = cc .wgpu_render_state @@ -59,6 +68,12 @@ impl EskinDesktopApp { config_panel: FloatingPanelState::new([840.0, 48.0], [128.0, 48.0]), config_state: ConfigPanelState::default(), stats_panel: FloatingPanelState::new([16.0, 520.0], [240.0, 48.0]), + recorder: Recorder::new(), + export_panel: FloatingPanelState::new([16.0, 280.0], [16.0, 280.0]), + export_path: String::new(), + matrix_config_panel: FloatingPanelState::new([840.0, 280.0], [400.0, 48.0]), + matrix_config: MatrixConfigState::default(), + signal_history: Vec::with_capacity(128), } } @@ -87,6 +102,17 @@ impl EskinDesktopApp { sample.cols, &mut self.pressure_matrix, ); + + // Feed data to recorder + self.recorder.add_frame(&sample.matrix); + + // Update signal history (sum of all pressure values) + let total: f32 = sample.matrix.iter().map(|v| *v as f32).sum(); + self.signal_history.push(total); + if self.signal_history.len() > 128 { + self.signal_history.remove(0); + } + self.data_log_frame += 1; if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 { log_pressure_sample(&sample.matrix, sample.rows, sample.cols); @@ -104,11 +130,11 @@ impl EskinDesktopApp { ui.painter().rect_filled( title_bar_rect, egui::CornerRadius::ZERO, - ENGINEERING_DARK.panel_deep, + ONE_DARK_PRO.panel_deep, ); ui.painter().line_segment( [title_bar_rect.left_bottom(), title_bar_rect.right_bottom()], - egui::Stroke::new(1.0, ENGINEERING_DARK.border), + egui::Stroke::new(1.0, ONE_DARK_PRO.border), ); // Drag-to-move: double-click to maximize, drag to move @@ -211,10 +237,14 @@ impl EskinDesktopApp { &mut self.connect_panel, &mut self.connect_state, &self.connection, + &self.recorder, + &mut self.export_path, ); draw_scene_panel(ctx, &mut self.scene_panel); draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state); draw_stats_panel(ctx, &mut self.stats_panel); + draw_export_panel(ctx, &mut self.export_panel, &self.recorder, &mut self.export_path); + draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config); } } diff --git a/src/main.rs b/src/main.rs index f70ad64..6f95400 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod render; mod serial_core; mod theme; mod ui; +mod recording; mod utils; use app::EskinDesktopApp; use eframe::egui; diff --git a/src/recording.rs b/src/recording.rs new file mode 100644 index 0000000..4da3ab1 --- /dev/null +++ b/src/recording.rs @@ -0,0 +1,441 @@ +//! Recording and CSV export for pressure sensor data. +//! +//! Provides two recording modes (Full and Snapshot), state management +//! (Idle / Recording / Paused), and CSV import/export for replay. + +use anyhow::{Context, Result}; +use std::fs::File; +use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +// ── Public types ──────────────────────────────────────────────────────── + +/// A single frame of pressure data captured at a point in time. +#[derive(Debug, Clone)] +pub struct Frame { + /// Pressure values, one per channel (same order every frame). + pub pressures: Vec, + /// Milliseconds elapsed since the recording started. + pub timestamp_ms: u64, +} + +/// Whether the recording captures everything from connect (Full) or +/// only between explicit start/stop calls (Snapshot). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordingMode { + Full, + Snapshot, +} + +/// Transient state of a recording. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordingState { + Idle, + Recording, + Paused, +} + +// ── Inner (unlocked) recorder ─────────────────────────────────────────── + +struct RecorderInner { + mode: RecordingMode, + state: RecordingState, + frames: Vec, + start: Option, + /// Accumulated wall-clock ms while paused (subtracted from elapsed). + paused_duration_ms: u64, + /// Instant when we entered Paused (None when not paused). + pause_start: Option, + /// Number of channels in the first frame (used for CSV header). + channel_count: Option, +} + +impl RecorderInner { + fn new(mode: RecordingMode) -> Self { + Self { + mode, + state: RecordingState::Idle, + frames: Vec::new(), + start: None, + paused_duration_ms: 0, + pause_start: None, + channel_count: None, + } + } + + fn elapsed_ms(&self) -> u64 { + let Some(start) = self.start else { return 0 }; + let raw = start.elapsed().as_millis() as u64; + let paused = if let Some(ps) = self.pause_start { + self.paused_duration_ms + ps.elapsed().as_millis() as u64 + } else { + self.paused_duration_ms + }; + raw.saturating_sub(paused) + } + + fn push_frame(&mut self, pressures: Vec) { + if self.channel_count.is_none() { + self.channel_count = Some(pressures.len()); + } + let ts = self.elapsed_ms(); + self.frames.push(Frame { + pressures, + timestamp_ms: ts, + }); + } +} + +// ── Thread-safe wrapper ───────────────────────────────────────────────── + +/// Thread-safe recorder. Clone the `Arc` to share across threads. +#[derive(Clone)] +pub struct Recorder { + inner: Arc>, +} + +impl Recorder { + /// Create a recorder in the given mode (starts in `Idle` state). + pub fn new(mode: RecordingMode) -> Self { + Self { + inner: Arc::new(Mutex::new(RecorderInner::new(mode))), + } + } + + // ── Lifecycle ─────────────────────────────────────────────────── + + /// Start a **Full** recording (records every frame pushed from now on). + pub fn start_full_recording(&self) -> Result<()> { + let mut r = self.inner.lock().unwrap(); + anyhow::ensure!( + r.state == RecordingState::Idle, + "can only start from Idle state" + ); + r.state = RecordingState::Recording; + r.start = Some(Instant::now()); + r.paused_duration_ms = 0; + r.pause_start = None; + r.frames.clear(); + r.channel_count = None; + Ok(()) + } + + /// Start a **Snapshot** recording window. + pub fn start_snapshot_recording(&self) -> Result<()> { + let mut r = self.inner.lock().unwrap(); + anyhow::ensure!( + r.state == RecordingState::Idle, + "can only start from Idle state" + ); + r.state = RecordingState::Recording; + r.start = Some(Instant::now()); + r.paused_duration_ms = 0; + r.pause_start = None; + r.frames.clear(); + r.channel_count = None; + Ok(()) + } + + /// Stop recording (transitions to `Idle`). + pub fn stop_recording(&self) -> Result<()> { + let mut r = self.inner.lock().unwrap(); + anyhow::ensure!( + r.state == RecordingState::Recording || r.state == RecordingState::Paused, + "nothing to stop" + ); + if r.state == RecordingState::Paused { + if let Some(ps) = r.pause_start.take() { + r.paused_duration_ms += ps.elapsed().as_millis() as u64; + } + } + r.state = RecordingState::Idle; + Ok(()) + } + + /// Pause an active recording. + pub fn pause_recording(&self) -> Result<()> { + let mut r = self.inner.lock().unwrap(); + anyhow::ensure!(r.state == RecordingState::Recording, "not recording"); + r.pause_start = Some(Instant::now()); + r.state = RecordingState::Paused; + Ok(()) + } + + /// Resume a paused recording. + pub fn resume_recording(&self) -> Result<()> { + let mut r = self.inner.lock().unwrap(); + anyhow::ensure!(r.state == RecordingState::Paused, "not paused"); + if let Some(ps) = r.pause_start.take() { + r.paused_duration_ms += ps.elapsed().as_millis() as u64; + } + r.state = RecordingState::Recording; + Ok(()) + } + + // ── Data ──────────────────────────────────────────────────────── + + /// Feed one frame of pressure data. Ignored when not recording. + pub fn add_frame(&self, pressures: &[u32]) { + let mut r = self.inner.lock().unwrap(); + if r.state == RecordingState::Recording { + r.push_frame(pressures.to_vec()); + } + } + + /// Number of recorded frames. + pub fn frame_count(&self) -> usize { + self.inner.lock().unwrap().frames.len() + } + + /// Whether the recorder is currently in the `Recording` state. + pub fn is_recording(&self) -> bool { + self.inner.lock().unwrap().state == RecordingState::Recording + } + + /// Current recording state. + pub fn state(&self) -> RecordingState { + self.inner.lock().unwrap().state + } + + /// Milliseconds elapsed (excludes paused time). + pub fn duration_ms(&self) -> u64 { + self.inner.lock().unwrap().elapsed_ms() + } + + /// Snapshot of all recorded frames. + pub fn recorded_frames(&self) -> Vec { + self.inner.lock().unwrap().frames.clone() + } + + // ── CSV export / import ───────────────────────────────────────── + + /// Export recorded frames to CSV. + /// + /// Header: `channel1,channel2,...,channelN,timestamp_ms` + pub fn export_csv>(&self, path: P) -> Result<()> { + let r = self.inner.lock().unwrap(); + anyhow::ensure!(!r.frames.is_empty(), "no frames to export"); + + let file = File::create(path.as_ref()) + .with_context(|| format!("creating {}", path.as_ref().display()))?; + let mut w = BufWriter::new(file); + + let n = r.channel_count.unwrap_or(r.frames[0].pressures.len()); + + // Header + for i in 0..n { + write!(w, "channel{}", i + 1)?; + if i < n - 1 { + write!(w, ",")?; + } + } + writeln!(w, ",timestamp_ms")?; + + // Rows + for frame in &r.frames { + for (i, val) in frame.pressures_iter().enumerate() { + write!(w, "{}", val)?; + if i < n - 1 { + write!(w, ",")?; + } + } + writeln!(w, ",{}", frame.timestamp_ms)?; + } + + w.flush()?; + Ok(()) + } + + /// Import frames from a CSV file (same format as `export_csv`). + /// + /// The recorder must be in `Idle` state. After import it stays `Idle` + /// so you can inspect / re-export; call `start_*` to continue recording. + pub fn import_csv>(&self, path: P) -> Result<()> { + let mut r = self.inner.lock().unwrap(); + anyhow::ensure!(r.state == RecordingState::Idle, "must be Idle to import"); + + let file = File::open(path.as_ref()) + .with_context(|| format!("opening {}", path.as_ref().display()))?; + let reader = BufReader::new(file); + let mut lines = reader.lines(); + + // Parse header to learn channel count + let header = lines + .next() + .context("empty CSV file")? + .context("reading header")?; + let cols: Vec<&str> = header.split(',').map(str::trim).collect(); + anyhow::ensure!(cols.len() >= 2, "need at least 1 channel + timestamp_ms"); + let n_channels = cols.len() - 1; // last column is timestamp_ms + + r.frames.clear(); + r.channel_count = Some(n_channels); + + let mut first_ts: Option = None; + + for (lineno, line) in lines.enumerate() { + let line = line.with_context(|| format!("reading line {}", lineno + 2))?; + let line = line.trim(); + if line.is_empty() { + continue; + } + let fields: Vec<&str> = line.split(',').map(str::trim).collect(); + anyhow::ensure!( + fields.len() == cols.len(), + "line {}: expected {} columns, got {}", + lineno + 2, + cols.len(), + fields.len() + ); + + let mut pressures = Vec::with_capacity(n_channels); + for f in &fields[..n_channels] { + pressures.push(f.parse::().with_context(|| { + format!("line {}: bad channel value '{}'", lineno + 2, f) + })?); + } + let raw_ts = fields[n_channels] + .parse::() + .with_context(|| format!("line {}: bad timestamp", lineno + 2))?; + + // Normalise timestamps so the first frame starts at 0 + let ts = if let Some(first) = first_ts { + raw_ts.saturating_sub(first) + } else { + first_ts = Some(raw_ts); + 0 + }; + + r.frames.push(Frame { + pressures, + timestamp_ms: ts, + }); + } + + // Set the start instant so duration_ms() reports the imported span + if !r.frames.is_empty() { + if let Some(last) = r.frames.last() { + // Pretend the recording happened `last.timestamp_ms` ago + // so that elapsed_ms() would return that value. + // We store a "fake" start by noting the offset. + r.start = Some(Instant::now() - std::time::Duration::from_millis(last.timestamp_ms)); + r.paused_duration_ms = 0; + } + } + + Ok(()) + } +} + +// ── Convenience constructors ──────────────────────────────────────────── + +impl Recorder { + /// Shorthand: new Full recorder. + pub fn full() -> Self { + Self::new(RecordingMode::Full) + } + + /// Shorthand: new Snapshot recorder. + pub fn snapshot() -> Self { + Self::new(RecordingMode::Snapshot) + } +} + +// ── Helper extension on Frame ─────────────────────────────────────────── + +impl Frame { + fn pressures_iter(&self) -> impl Iterator + '_ { + self.pressures.iter().copied() + } +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + #[test] + fn full_recording_lifecycle() { + let rec = Recorder::full(); + assert_eq!(rec.state(), RecordingState::Idle); + assert!(!rec.is_recording()); + assert_eq!(rec.frame_count(), 0); + + rec.start_full_recording().unwrap(); + assert!(rec.is_recording()); + + rec.add_frame(&[10, 20, 30]); + rec.add_frame(&[40, 50, 60]); + assert_eq!(rec.frame_count(), 2); + + rec.stop_recording().unwrap(); + assert_eq!(rec.state(), RecordingState::Idle); + assert!(!rec.is_recording()); + assert!(rec.duration_ms() < 500); + } + + #[test] + fn snapshot_pause_resume() { + let rec = Recorder::snapshot(); + rec.start_snapshot_recording().unwrap(); + + rec.add_frame(&[1, 2]); + rec.pause_recording().unwrap(); + assert_eq!(rec.state(), RecordingState::Paused); + rec.add_frame(&[9, 9]); // should be ignored + assert_eq!(rec.frame_count(), 1); + + rec.resume_recording().unwrap(); + rec.add_frame(&[3, 4]); + assert_eq!(rec.frame_count(), 2); + + rec.stop_recording().unwrap(); + } + + #[test] + fn csv_round_trip() { + let rec = Recorder::full(); + rec.start_full_recording().unwrap(); + rec.add_frame(&[100, 200, 300]); + rec.add_frame(&[101, 201, 301]); + rec.stop_recording().unwrap(); + + let dir = std::env::temp_dir().join("eskin_recording_test"); + std::fs::create_dir_all(&dir).unwrap(); + let csv_path = dir.join("test_roundtrip.csv"); + rec.export_csv(&csv_path).unwrap(); + + let rec2 = Recorder::snapshot(); + rec2.import_csv(&csv_path).unwrap(); + assert_eq!(rec2.frame_count(), 2); + + let frames = rec2.recorded_frames(); + assert_eq!(frames[0].pressures, vec![100, 200, 300]); + assert_eq!(frames[1].pressures, vec![101, 201, 301]); + assert_eq!(frames[0].timestamp_ms, 0); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn thread_safety() { + let rec = Recorder::full(); + let rec2 = rec.clone(); + + rec.start_full_recording().unwrap(); + + let h = thread::spawn(move || { + for i in 0..100u32 { + rec2.add_frame(&[i, i + 1, i + 2]); + } + }); + h.join().unwrap(); + + rec.stop_recording().unwrap(); + assert_eq!(rec.frame_count(), 100); + } +} diff --git a/src/theme.rs b/src/theme.rs index db64ae7..2627c67 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -15,20 +15,27 @@ pub struct AppTheme { pub radius: u8, } -pub const ENGINEERING_DARK: AppTheme = AppTheme { - bg: egui::Color32::from_rgb(33, 35, 44), - panel: egui::Color32::from_rgb(25, 36, 48), - panel_strong: egui::Color32::from_rgb(47, 58, 70), - panel_deep: egui::Color32::from_rgb(14, 18, 24), - border: egui::Color32::from_rgb(53, 75, 92), - border_soft: egui::Color32::from_rgb(36, 53, 66), - text: egui::Color32::from_rgb(242, 246, 252), - text_dim: egui::Color32::from_rgb(206, 216, 230), - accent: egui::Color32::from_rgb(255, 118, 47), - accent_hot: egui::Color32::from_rgb(255, 169, 77), - radius: 2, +pub const ONE_DARK_PRO: AppTheme = AppTheme { + bg: egui::Color32::from_rgb(40, 44, 52), // #282C34 editor background + panel: egui::Color32::from_rgb(33, 37, 43), // #21252B sidebar/darker + panel_strong: egui::Color32::from_rgb(44, 49, 58), // #2C313A slightly lighter + panel_deep: egui::Color32::from_rgb(27, 31, 39), // #1B1F27 darkest + border: egui::Color32::from_rgb(62, 68, 81), // #3E4451 border + border_soft: egui::Color32::from_rgb(44, 49, 58), // #2C313A soft border + text: egui::Color32::from_rgb(171, 178, 191), // #ABB2BF default text + text_dim: egui::Color32::from_rgb(92, 99, 112), // #5C6370 dimmed text + accent: egui::Color32::from_rgb(198, 120, 221), // #C678DD purple (signature) + accent_hot: egui::Color32::from_rgb(229, 192, 123), // #E5C07B yellow/gold + radius: 3, }; +// Additional One Dark Pro accent colors (standalone constants) +pub const ACCENT_GREEN: egui::Color32 = egui::Color32::from_rgb(152, 195, 121); // #98C379 +pub const ACCENT_RED: egui::Color32 = egui::Color32::from_rgb(224, 108, 117); // #E06C75 +pub const ACCENT_BLUE: egui::Color32 = egui::Color32::from_rgb(97, 175, 239); // #61AFEF +pub const ACCENT_CYAN: egui::Color32 = egui::Color32::from_rgb(86, 182, 194); // #56B6C2 +pub const ACCENT_ORANGE: egui::Color32 = egui::Color32::from_rgb(209, 154, 102); // #D19A66 + pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) { let mut visuals = egui::Visuals::dark(); visuals.override_text_color = Some(theme.text); @@ -39,7 +46,7 @@ pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) { visuals.faint_bg_color = theme.panel_strong; visuals.code_bg_color = theme.panel_deep; visuals.warn_fg_color = theme.accent_hot; - visuals.error_fg_color = egui::Color32::from_rgb(255, 98, 82); + visuals.error_fg_color = ACCENT_RED; visuals.widgets.noninteractive.bg_fill = theme.panel_strong; visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft); @@ -47,13 +54,13 @@ pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) { visuals.widgets.inactive.bg_fill = theme.panel_strong; visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft); visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, theme.text); - visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(58, 69, 82); + visuals.widgets.hovered.bg_fill = egui::Color32::from_rgb(55, 61, 72); visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, theme.border); visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, theme.text); visuals.widgets.active.bg_fill = theme.accent; visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, theme.accent_hot); visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE); - visuals.widgets.open.bg_fill = egui::Color32::from_rgb(43, 55, 68); + visuals.widgets.open.bg_fill = egui::Color32::from_rgb(50, 56, 66); visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, theme.border); visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, theme.text); @@ -126,9 +133,9 @@ pub fn apply_fonts(ctx: &egui::Context) { pub fn panel_frame(ctx: &egui::Context) -> egui::Frame { let style = ctx.global_style(); egui::Frame::window(&style) - .fill(ENGINEERING_DARK.panel) - .stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border)) - .corner_radius(egui::CornerRadius::same(ENGINEERING_DARK.radius)) + .fill(ONE_DARK_PRO.panel) + .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border)) + .corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius)) .shadow(egui::epaint::Shadow { offset: [0, 12], blur: 26, @@ -139,23 +146,23 @@ pub fn panel_frame(ctx: &egui::Context) -> egui::Frame { pub fn group_frame() -> egui::Frame { egui::Frame::new() - .fill(ENGINEERING_DARK.panel_deep) - .stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft)) + .fill(ONE_DARK_PRO.panel_deep) + .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft)) .corner_radius(egui::CornerRadius::same(2)) .inner_margin(egui::Margin::symmetric(6, 5)) } pub fn tag_button(label: impl Into) -> egui::Button<'static> { egui::Button::new(label) - .fill(ENGINEERING_DARK.panel_strong) - .stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border)) + .fill(ONE_DARK_PRO.panel_strong) + .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border)) .corner_radius(egui::CornerRadius::same(2)) } pub fn dim_text() -> egui::Color32 { - ENGINEERING_DARK.text_dim + ONE_DARK_PRO.text_dim } pub fn accent_text() -> egui::Color32 { - ENGINEERING_DARK.accent_hot + ONE_DARK_PRO.accent_hot } diff --git a/src/ui.rs b/src/ui.rs index cb4219d..61e0f79 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,7 +2,8 @@ use eframe::egui; use crate::{ connection::{ConnectionManager, ConnectionState}, - theme::{ENGINEERING_DARK, accent_text, dim_text, group_frame, panel_frame, tag_button}, + recording::Recorder, + theme::{ONE_DARK_PRO, ACCENT_BLUE, ACCENT_CYAN, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, accent_text, dim_text, group_frame, panel_frame, tag_button}, utils::serial_enum, }; @@ -130,6 +131,8 @@ pub fn draw_connect_panel( panel: &mut FloatingPanelState, config: &mut ConnectPanelState, connection: &ConnectionManager, + recorder: &Recorder, + export_path: &mut String, ) { let conn_state = connection.state(); let is_connected = matches!( @@ -167,7 +170,7 @@ pub fn draw_connect_panel( ui.vertical(|ui| { ui.horizontal(|ui| { - ui.colored_label(ENGINEERING_DARK.text, "串口"); + ui.colored_label(ONE_DARK_PRO.text, "串口"); egui::ComboBox::from_id_salt("connect_ports") .width(130.0) .selected_text(if config.selected_port.is_empty() { @@ -188,8 +191,8 @@ pub fn draw_connect_panel( if ui .add( egui::Button::new("⟳") - .fill(ENGINEERING_DARK.panel_strong) - .stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft)) + .fill(ONE_DARK_PRO.panel_strong) + .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft)) .min_size(egui::vec2(24.0, 20.0)), ) .on_hover_text("刷新串口") @@ -218,12 +221,12 @@ pub fn draw_connect_panel( ui.add_enabled_ui(config.manual, |ui| { ui.horizontal(|ui| { - ui.colored_label(ENGINEERING_DARK.text_dim, "行"); + ui.colored_label(ONE_DARK_PRO.text_dim, "行"); ui.add_sized( egui::vec2(48.0, 20.0), egui::DragValue::new(&mut config.rows).range(1..=64), ); - ui.colored_label(ENGINEERING_DARK.text_dim, "列"); + ui.colored_label(ONE_DARK_PRO.text_dim, "列"); ui.add_sized( egui::vec2(48.0, 20.0), egui::DragValue::new(&mut config.cols).range(1..=64), @@ -246,7 +249,7 @@ pub fn draw_connect_panel( ConnectionState::Error => "连接错误", }; let status_color = match conn_state { - ConnectionState::Disconnected => ENGINEERING_DARK.text_dim, + ConnectionState::Disconnected => ONE_DARK_PRO.text_dim, ConnectionState::Connecting => egui::Color32::from_rgb(200, 180, 60), ConnectionState::Connected => egui::Color32::from_rgb(158, 184, 101), ConnectionState::Streaming => egui::Color32::from_rgb(100, 200, 255), @@ -262,12 +265,12 @@ pub fn draw_connect_panel( let btn_fill = if is_connected { egui::Color32::from_rgb(180, 60, 60) } else { - ENGINEERING_DARK.accent + ONE_DARK_PRO.accent }; let btn_stroke = if is_connected { egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 80, 80)) } else { - egui::Stroke::new(1.0, ENGINEERING_DARK.accent_hot) + egui::Stroke::new(1.0, ONE_DARK_PRO.accent_hot) }; if ui @@ -292,6 +295,11 @@ pub fn draw_connect_panel( } } }); + + // Recording toolbar (visible when connected) + if is_connected { + draw_recording_toolbar(ui, recorder, export_path); + } }); }); } @@ -361,8 +369,8 @@ fn draw_connection_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) { if ui .add( egui::Button::new(button_text) - .fill(ENGINEERING_DARK.accent) - .stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.accent_hot)) + .fill(ONE_DARK_PRO.accent) + .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.accent_hot)) .min_size(egui::vec2(120.0, 30.0)), ) .clicked() @@ -504,8 +512,8 @@ pub fn icon_button_sized<'a>( egui::Image::new(source).fit_to_exact_size(egui::vec2(size.y - 8.0, size.y - 8.0)), ), } - .fill(ENGINEERING_DARK.panel_strong) - .stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border)) + .fill(ONE_DARK_PRO.panel_strong) + .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border)) .corner_radius(egui::CornerRadius::same(2)) .min_size(size); @@ -515,14 +523,14 @@ pub fn icon_button_sized<'a>( fn mode_button(ui: &mut egui::Ui, mode: &mut SerialMode, value: SerialMode, label: &'static str) { let selected = *mode == value; let fill = if selected { - ENGINEERING_DARK.accent + ONE_DARK_PRO.accent } else { - ENGINEERING_DARK.panel_strong + ONE_DARK_PRO.panel_strong }; let stroke = if selected { - ENGINEERING_DARK.accent_hot + ONE_DARK_PRO.accent_hot } else { - ENGINEERING_DARK.border_soft + ONE_DARK_PRO.border_soft }; if ui @@ -749,8 +757,8 @@ fn center_panel_shell( add_contents: impl FnOnce(&mut egui::Ui) -> R, ) -> egui::InnerResponse { egui::Frame::new() - .fill(ENGINEERING_DARK.panel) - .corner_radius(egui::CornerRadius::same(ENGINEERING_DARK.radius)) + .fill(ONE_DARK_PRO.panel) + .corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius)) .inner_margin(egui::Margin::symmetric(10, 8)) .show(ui, |ui| { ui.set_width(width); @@ -805,7 +813,7 @@ fn paint_integrated_handle( let center = rect.center(); if !expanded { let fill = if hovered { - ENGINEERING_DARK.panel_strong + ONE_DARK_PRO.panel_strong } else { egui::Color32::from_rgb(31, 41, 52) }; @@ -818,11 +826,11 @@ fn paint_integrated_handle( ui.painter().add(egui::Shape::convex_polygon( points.clone(), fill, - egui::Stroke::new(1.0, ENGINEERING_DARK.border), + egui::Stroke::new(1.0, ONE_DARK_PRO.border), )); ui.painter().line_segment( [points[2], points[3]], - egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft), + egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft), ); } @@ -834,9 +842,9 @@ fn paint_integrated_handle( }; let arrow_center = egui::pos2(arrow_center_x, center.y); let arrow_color = if hovered { - ENGINEERING_DARK.accent_hot + ONE_DARK_PRO.accent_hot } else { - ENGINEERING_DARK.text_dim + ONE_DARK_PRO.text_dim }; ui.painter().line_segment( [ @@ -858,13 +866,13 @@ fn paint_integrated_handle( fonts.layout_no_wrap( label.to_owned(), egui::FontId::proportional(12.0), - ENGINEERING_DARK.text, + ONE_DARK_PRO.text, ) }); ui.painter().galley( egui::pos2(rect.left() + 14.0, center.y - galley.size().y * 0.5 - 1.0), galley, - ENGINEERING_DARK.text, + ONE_DARK_PRO.text, ); } } @@ -876,13 +884,13 @@ fn paint_integrated_center_panel( handle_on_bottom: bool, ) { let painter = ui.painter(); - let stroke = egui::Stroke::new(1.0, ENGINEERING_DARK.border); - let accent_stroke = egui::Stroke::new(1.2, ENGINEERING_DARK.border_soft); + let stroke = egui::Stroke::new(1.0, ONE_DARK_PRO.border); + let accent_stroke = egui::Stroke::new(1.2, ONE_DARK_PRO.border_soft); let top = rect.top(); let left = rect.left(); let right = rect.right(); let bottom = rect.bottom(); - let radius = ENGINEERING_DARK.radius as f32; + let radius = ONE_DARK_PRO.radius as f32; let tab_left = handle_rect.left() - 18.0; let tab_right = handle_rect.right() + 18.0; let tab_recess = if handle_on_bottom { @@ -965,3 +973,280 @@ fn paint_integrated_center_panel( }; painter.add(egui::Shape::line(tab_points, accent_stroke)); } + +// ── Signal Chart (sparkline) ─────────────────────────────────────────── + +/// Draw a mini sparkline chart with current/min/max labels. +pub fn draw_signal_chart( + ui: &mut egui::Ui, + values: &[f32], + label: &str, + color: egui::Color32, +) { + let chart_height = 48.0; + let chart_width = ui.available_width().min(200.0); + + group_frame().show(ui, |ui| { + ui.horizontal(|ui| { + ui.colored_label(color, label); + if let (Some(&last), Some(&min), Some(&max)) = ( + values.last(), + values.iter().min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)), + values.iter().max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)), + ) { + ui.colored_label(ONE_DARK_PRO.text, format!("{:.0}", last)); + ui.colored_label(ONE_DARK_PRO.text_dim, format!("↓{:.0} ↑{:.0}", min, max)); + } + }); + + let (rect, _response) = ui + .allocate_exact_size(egui::vec2(chart_width, chart_height), egui::Sense::hover()); + + if values.len() < 2 { + return; + } + + let painter = ui.painter_at(rect); + painter.rect_filled(rect, egui::CornerRadius::same(2), ONE_DARK_PRO.panel_deep); + + let min_val = values + .iter() + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .copied() + .unwrap_or(0.0); + let max_val = values + .iter() + .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .copied() + .unwrap_or(1.0); + let range = (max_val - min_val).max(1.0); + + let points: Vec = values + .iter() + .enumerate() + .map(|(i, &v)| { + let x = rect.left() + (i as f32 / (values.len() - 1) as f32) * rect.width(); + let y = rect.bottom() - ((v - min_val) / range) * rect.height(); + egui::pos2(x, y) + }) + .collect(); + + painter.add(egui::Shape::line(points, egui::Stroke::new(1.5, color))); + }); +} + +// ── Recording Toolbar ────────────────────────────────────────────────── + +/// Draw recording controls inside the connect panel. +pub fn draw_recording_toolbar( + ui: &mut egui::Ui, + recorder: &Recorder, + export_path: &mut String, +) { + let is_recording = recorder.is_recording(); + let frame_count = recorder.frame_count(); + let duration_ms = recorder.duration_ms(); + + ui.separator(); + ui.horizontal(|ui| { + ui.colored_label(ACCENT_RED, "● REC"); + ui.colored_label( + ONE_DARK_PRO.text, + format!("{} 帧 | {:.1}s", frame_count, duration_ms as f64 / 1000.0), + ); + }); + + ui.horizontal(|ui| { + // Full recording + let rec_btn = if is_recording { + tag_button("⏹ 停止") + } else { + egui::Button::new(egui::RichText::new("● 全量录制").color(egui::Color32::WHITE)) + .fill(ACCENT_RED) + .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border)) + .corner_radius(egui::CornerRadius::same(2)) + }; + if ui.add(rec_btn).clicked() { + if is_recording { + recorder.stop_recording(); + } else { + recorder.start_full_recording(); + } + } + + ui.add_space(4.0); + + // Snapshot recording + let snap_btn = if is_recording { + tag_button("⏹ 快照停止") + } else { + egui::Button::new(egui::RichText::new("✂ 快照录制").color(egui::Color32::WHITE)) + .fill(ACCENT_ORANGE) + .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border)) + .corner_radius(egui::CornerRadius::same(2)) + }; + if ui.add(snap_btn).clicked() { + if is_recording { + recorder.stop_recording(); + } else { + recorder.start_snapshot_recording(); + } + } + + ui.add_space(4.0); + + // Pause/Resume + if is_recording { + if ui.add(tag_button("⏸ 暂停")).clicked() { + recorder.pause_recording(); + } + } + + ui.add_space(8.0); + + // Export + if ui + .add( + egui::Button::new(egui::RichText::new("📤 导出CSV").color(egui::Color32::WHITE)) + .fill(ACCENT_BLUE) + .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border)) + .corner_radius(egui::CornerRadius::same(2)), + ) + .clicked() + { + let path = if export_path.is_empty() { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + format!("eskin_export_{}.csv", ts) + } else { + export_path.clone() + }; + if let Err(e) = recorder.export_csv(&path) { + eprintln!("[export] error: {e}"); + } + } + + // Import + if ui + .add( + egui::Button::new(egui::RichText::new("📥 导入CSV").color(egui::Color32::WHITE)) + .fill(ACCENT_GREEN) + .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border)) + .corner_radius(egui::CornerRadius::same(2)), + ) + .clicked() + { + if let Err(e) = recorder.import_csv(export_path) { + eprintln!("[import] error: {e}"); + } + } + }); + + // Export path + ui.horizontal(|ui| { + ui.colored_label(dim_text(), "导出路径"); + ui.add_sized( + egui::vec2(ui.available_width() - 80.0, 20.0), + egui::TextEdit::singleline(export_path).hint_text("eskin_export_*.csv"), + ); + }); +} + +// ── Export Panel (floating) ──────────────────────────────────────────── + +/// Draw the export/recording floating panel. +pub fn draw_export_panel( + ctx: &egui::Context, + panel: &mut FloatingPanelState, + recorder: &Recorder, + export_path: &mut String, +) { + draw_floating_panel(ctx, panel, "录制导出", "export_panel", |ui| { + ui.set_min_width(300.0); + draw_recording_toolbar(ui, recorder, export_path); + }); +} + +// ── Matrix Config Panel ──────────────────────────────────────────────── + +pub struct MatrixConfigState { + pub rows: u32, + pub cols: u32, + pub color_min: f32, + pub color_max: f32, +} + +impl Default for MatrixConfigState { + fn default() -> Self { + Self { + rows: 12, + cols: 7, + color_min: 0.0, + color_max: 7000.0, + } + } +} + +/// Draw matrix configuration floating panel. +pub fn draw_matrix_config_panel( + ctx: &egui::Context, + panel: &mut FloatingPanelState, + config: &mut MatrixConfigState, +) { + draw_floating_panel(ctx, panel, "矩阵配置", "matrix_config_panel", |ui| { + ui.set_min_width(280.0); + + ui.horizontal(|ui| { + ui.colored_label(dim_text(), "矩阵尺寸"); + ui.add_space(8.0); + ui.colored_label(ONE_DARK_PRO.text, "行"); + ui.add_sized(egui::vec2(56.0, 20.0), egui::DragValue::new(&mut config.rows).range(1..=128)); + ui.colored_label(ONE_DARK_PRO.text, "列"); + ui.add_sized(egui::vec2(56.0, 20.0), egui::DragValue::new(&mut config.cols).range(1..=128)); + }); + + ui.add_space(4.0); + + // Presets + ui.horizontal(|ui| { + ui.colored_label(dim_text(), "预设"); + for (r, c, name) in &[ + (12, 7, "12×7"), + (24, 12, "24×12"), + (32, 16, "32×16"), + (48, 24, "48×24"), + (64, 32, "64×32"), + ] { + if ui.add(tag_button(*name)).clicked() { + config.rows = *r; + config.cols = *c; + } + } + }); + + ui.add_space(4.0); + + ui.horizontal(|ui| { + ui.colored_label(dim_text(), "色域范围"); + ui.add_space(8.0); + ui.colored_label(ONE_DARK_PRO.text, "最小"); + ui.add_sized( + egui::vec2(72.0, 20.0), + egui::DragValue::new(&mut config.color_min).range(0.0..=10000.0).speed(100.0), + ); + ui.colored_label(ONE_DARK_PRO.text, "最大"); + ui.add_sized( + egui::vec2(72.0, 20.0), + egui::DragValue::new(&mut config.color_max).range(1.0..=10000.0).speed(100.0), + ); + }); + + ui.add_space(8.0); + + if ui.add(tag_button("重置默认")).clicked() { + *config = MatrixConfigState::default(); + } + }); +}