feat: JE-Skin 功能迁移 — One Dark Pro 工业风 + 录制导出

## 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<Mutex<>>
- 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 帧)
This commit is contained in:
2026-05-20 17:27:38 +08:00
parent 2f16c4762f
commit 174adb5366
5 changed files with 823 additions and 59 deletions

View File

@@ -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<f32>,
}
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);
}
}

View File

@@ -5,6 +5,7 @@ mod render;
mod serial_core;
mod theme;
mod ui;
mod recording;
mod utils;
use app::EskinDesktopApp;
use eframe::egui;

441
src/recording.rs Normal file
View File

@@ -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<u32>,
/// 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<Frame>,
start: Option<Instant>,
/// 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<Instant>,
/// Number of channels in the first frame (used for CSV header).
channel_count: Option<usize>,
}
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<u32>) {
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<Mutex<RecorderInner>>,
}
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<Frame> {
self.inner.lock().unwrap().frames.clone()
}
// ── CSV export / import ─────────────────────────────────────────
/// Export recorded frames to CSV.
///
/// Header: `channel1,channel2,...,channelN,timestamp_ms`
pub fn export_csv<P: AsRef<Path>>(&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<P: AsRef<Path>>(&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<u64> = 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::<u32>().with_context(|| {
format!("line {}: bad channel value '{}'", lineno + 2, f)
})?);
}
let raw_ts = fields[n_channels]
.parse::<u64>()
.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<Item = u32> + '_ {
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);
}
}

View File

@@ -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::WidgetText>) -> 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
}

343
src/ui.rs
View File

@@ -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<R>(
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::InnerResponse<R> {
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<egui::Pos2> = 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();
}
});
}