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:
42
src/app.rs
42
src/app.rs
@@ -2,15 +2,17 @@ use eframe::{egui, egui_wgpu};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::connection::ConnectionManager;
|
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::{
|
use crate::{
|
||||||
matrix::{MATRIX_COLS, MATRIX_ROWS},
|
matrix::{MATRIX_COLS, MATRIX_ROWS},
|
||||||
render::{
|
render::{
|
||||||
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback,
|
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback,
|
||||||
},
|
},
|
||||||
ui::{
|
ui::{
|
||||||
ConfigPanelState, ConnectPanelState, FloatingPanelState, draw_config_panel,
|
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
|
||||||
draw_connect_panel, draw_scene_panel, draw_stats_panel,
|
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_panel: FloatingPanelState,
|
||||||
config_state: ConfigPanelState,
|
config_state: ConfigPanelState,
|
||||||
stats_panel: FloatingPanelState,
|
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 {
|
impl EskinDesktopApp {
|
||||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||||||
apply_fonts(&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
|
let wgpu_state = cc
|
||||||
.wgpu_render_state
|
.wgpu_render_state
|
||||||
@@ -59,6 +68,12 @@ impl EskinDesktopApp {
|
|||||||
config_panel: FloatingPanelState::new([840.0, 48.0], [128.0, 48.0]),
|
config_panel: FloatingPanelState::new([840.0, 48.0], [128.0, 48.0]),
|
||||||
config_state: ConfigPanelState::default(),
|
config_state: ConfigPanelState::default(),
|
||||||
stats_panel: FloatingPanelState::new([16.0, 520.0], [240.0, 48.0]),
|
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,
|
sample.cols,
|
||||||
&mut self.pressure_matrix,
|
&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;
|
self.data_log_frame += 1;
|
||||||
if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 {
|
if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 {
|
||||||
log_pressure_sample(&sample.matrix, sample.rows, sample.cols);
|
log_pressure_sample(&sample.matrix, sample.rows, sample.cols);
|
||||||
@@ -104,11 +130,11 @@ impl EskinDesktopApp {
|
|||||||
ui.painter().rect_filled(
|
ui.painter().rect_filled(
|
||||||
title_bar_rect,
|
title_bar_rect,
|
||||||
egui::CornerRadius::ZERO,
|
egui::CornerRadius::ZERO,
|
||||||
ENGINEERING_DARK.panel_deep,
|
ONE_DARK_PRO.panel_deep,
|
||||||
);
|
);
|
||||||
ui.painter().line_segment(
|
ui.painter().line_segment(
|
||||||
[title_bar_rect.left_bottom(), title_bar_rect.right_bottom()],
|
[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
|
// Drag-to-move: double-click to maximize, drag to move
|
||||||
@@ -211,10 +237,14 @@ impl EskinDesktopApp {
|
|||||||
&mut self.connect_panel,
|
&mut self.connect_panel,
|
||||||
&mut self.connect_state,
|
&mut self.connect_state,
|
||||||
&self.connection,
|
&self.connection,
|
||||||
|
&self.recorder,
|
||||||
|
&mut self.export_path,
|
||||||
);
|
);
|
||||||
draw_scene_panel(ctx, &mut self.scene_panel);
|
draw_scene_panel(ctx, &mut self.scene_panel);
|
||||||
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state);
|
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state);
|
||||||
draw_stats_panel(ctx, &mut self.stats_panel);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod render;
|
|||||||
mod serial_core;
|
mod serial_core;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
mod recording;
|
||||||
mod utils;
|
mod utils;
|
||||||
use app::EskinDesktopApp;
|
use app::EskinDesktopApp;
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
|||||||
441
src/recording.rs
Normal file
441
src/recording.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/theme.rs
55
src/theme.rs
@@ -15,20 +15,27 @@ pub struct AppTheme {
|
|||||||
pub radius: u8,
|
pub radius: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const ENGINEERING_DARK: AppTheme = AppTheme {
|
pub const ONE_DARK_PRO: AppTheme = AppTheme {
|
||||||
bg: egui::Color32::from_rgb(33, 35, 44),
|
bg: egui::Color32::from_rgb(40, 44, 52), // #282C34 editor background
|
||||||
panel: egui::Color32::from_rgb(25, 36, 48),
|
panel: egui::Color32::from_rgb(33, 37, 43), // #21252B sidebar/darker
|
||||||
panel_strong: egui::Color32::from_rgb(47, 58, 70),
|
panel_strong: egui::Color32::from_rgb(44, 49, 58), // #2C313A slightly lighter
|
||||||
panel_deep: egui::Color32::from_rgb(14, 18, 24),
|
panel_deep: egui::Color32::from_rgb(27, 31, 39), // #1B1F27 darkest
|
||||||
border: egui::Color32::from_rgb(53, 75, 92),
|
border: egui::Color32::from_rgb(62, 68, 81), // #3E4451 border
|
||||||
border_soft: egui::Color32::from_rgb(36, 53, 66),
|
border_soft: egui::Color32::from_rgb(44, 49, 58), // #2C313A soft border
|
||||||
text: egui::Color32::from_rgb(242, 246, 252),
|
text: egui::Color32::from_rgb(171, 178, 191), // #ABB2BF default text
|
||||||
text_dim: egui::Color32::from_rgb(206, 216, 230),
|
text_dim: egui::Color32::from_rgb(92, 99, 112), // #5C6370 dimmed text
|
||||||
accent: egui::Color32::from_rgb(255, 118, 47),
|
accent: egui::Color32::from_rgb(198, 120, 221), // #C678DD purple (signature)
|
||||||
accent_hot: egui::Color32::from_rgb(255, 169, 77),
|
accent_hot: egui::Color32::from_rgb(229, 192, 123), // #E5C07B yellow/gold
|
||||||
radius: 2,
|
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) {
|
pub fn apply_theme(ctx: &egui::Context, theme: &AppTheme) {
|
||||||
let mut visuals = egui::Visuals::dark();
|
let mut visuals = egui::Visuals::dark();
|
||||||
visuals.override_text_color = Some(theme.text);
|
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.faint_bg_color = theme.panel_strong;
|
||||||
visuals.code_bg_color = theme.panel_deep;
|
visuals.code_bg_color = theme.panel_deep;
|
||||||
visuals.warn_fg_color = theme.accent_hot;
|
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_fill = theme.panel_strong;
|
||||||
visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft);
|
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_fill = theme.panel_strong;
|
||||||
visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, theme.border_soft);
|
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.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.bg_stroke = egui::Stroke::new(1.0, theme.border);
|
||||||
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
||||||
visuals.widgets.active.bg_fill = theme.accent;
|
visuals.widgets.active.bg_fill = theme.accent;
|
||||||
visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, theme.accent_hot);
|
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.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.bg_stroke = egui::Stroke::new(1.0, theme.border);
|
||||||
visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, theme.text);
|
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 {
|
pub fn panel_frame(ctx: &egui::Context) -> egui::Frame {
|
||||||
let style = ctx.global_style();
|
let style = ctx.global_style();
|
||||||
egui::Frame::window(&style)
|
egui::Frame::window(&style)
|
||||||
.fill(ENGINEERING_DARK.panel)
|
.fill(ONE_DARK_PRO.panel)
|
||||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border))
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||||
.corner_radius(egui::CornerRadius::same(ENGINEERING_DARK.radius))
|
.corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius))
|
||||||
.shadow(egui::epaint::Shadow {
|
.shadow(egui::epaint::Shadow {
|
||||||
offset: [0, 12],
|
offset: [0, 12],
|
||||||
blur: 26,
|
blur: 26,
|
||||||
@@ -139,23 +146,23 @@ pub fn panel_frame(ctx: &egui::Context) -> egui::Frame {
|
|||||||
|
|
||||||
pub fn group_frame() -> egui::Frame {
|
pub fn group_frame() -> egui::Frame {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(ENGINEERING_DARK.panel_deep)
|
.fill(ONE_DARK_PRO.panel_deep)
|
||||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft))
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft))
|
||||||
.corner_radius(egui::CornerRadius::same(2))
|
.corner_radius(egui::CornerRadius::same(2))
|
||||||
.inner_margin(egui::Margin::symmetric(6, 5))
|
.inner_margin(egui::Margin::symmetric(6, 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tag_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
|
pub fn tag_button(label: impl Into<egui::WidgetText>) -> egui::Button<'static> {
|
||||||
egui::Button::new(label)
|
egui::Button::new(label)
|
||||||
.fill(ENGINEERING_DARK.panel_strong)
|
.fill(ONE_DARK_PRO.panel_strong)
|
||||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border))
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||||
.corner_radius(egui::CornerRadius::same(2))
|
.corner_radius(egui::CornerRadius::same(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dim_text() -> egui::Color32 {
|
pub fn dim_text() -> egui::Color32 {
|
||||||
ENGINEERING_DARK.text_dim
|
ONE_DARK_PRO.text_dim
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn accent_text() -> egui::Color32 {
|
pub fn accent_text() -> egui::Color32 {
|
||||||
ENGINEERING_DARK.accent_hot
|
ONE_DARK_PRO.accent_hot
|
||||||
}
|
}
|
||||||
|
|||||||
343
src/ui.rs
343
src/ui.rs
@@ -2,7 +2,8 @@ use eframe::egui;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
connection::{ConnectionManager, ConnectionState},
|
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,
|
utils::serial_enum,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,6 +131,8 @@ pub fn draw_connect_panel(
|
|||||||
panel: &mut FloatingPanelState,
|
panel: &mut FloatingPanelState,
|
||||||
config: &mut ConnectPanelState,
|
config: &mut ConnectPanelState,
|
||||||
connection: &ConnectionManager,
|
connection: &ConnectionManager,
|
||||||
|
recorder: &Recorder,
|
||||||
|
export_path: &mut String,
|
||||||
) {
|
) {
|
||||||
let conn_state = connection.state();
|
let conn_state = connection.state();
|
||||||
let is_connected = matches!(
|
let is_connected = matches!(
|
||||||
@@ -167,7 +170,7 @@ pub fn draw_connect_panel(
|
|||||||
|
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.colored_label(ENGINEERING_DARK.text, "串口");
|
ui.colored_label(ONE_DARK_PRO.text, "串口");
|
||||||
egui::ComboBox::from_id_salt("connect_ports")
|
egui::ComboBox::from_id_salt("connect_ports")
|
||||||
.width(130.0)
|
.width(130.0)
|
||||||
.selected_text(if config.selected_port.is_empty() {
|
.selected_text(if config.selected_port.is_empty() {
|
||||||
@@ -188,8 +191,8 @@ pub fn draw_connect_panel(
|
|||||||
if ui
|
if ui
|
||||||
.add(
|
.add(
|
||||||
egui::Button::new("⟳")
|
egui::Button::new("⟳")
|
||||||
.fill(ENGINEERING_DARK.panel_strong)
|
.fill(ONE_DARK_PRO.panel_strong)
|
||||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft))
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft))
|
||||||
.min_size(egui::vec2(24.0, 20.0)),
|
.min_size(egui::vec2(24.0, 20.0)),
|
||||||
)
|
)
|
||||||
.on_hover_text("刷新串口")
|
.on_hover_text("刷新串口")
|
||||||
@@ -218,12 +221,12 @@ pub fn draw_connect_panel(
|
|||||||
|
|
||||||
ui.add_enabled_ui(config.manual, |ui| {
|
ui.add_enabled_ui(config.manual, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.colored_label(ENGINEERING_DARK.text_dim, "行");
|
ui.colored_label(ONE_DARK_PRO.text_dim, "行");
|
||||||
ui.add_sized(
|
ui.add_sized(
|
||||||
egui::vec2(48.0, 20.0),
|
egui::vec2(48.0, 20.0),
|
||||||
egui::DragValue::new(&mut config.rows).range(1..=64),
|
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(
|
ui.add_sized(
|
||||||
egui::vec2(48.0, 20.0),
|
egui::vec2(48.0, 20.0),
|
||||||
egui::DragValue::new(&mut config.cols).range(1..=64),
|
egui::DragValue::new(&mut config.cols).range(1..=64),
|
||||||
@@ -246,7 +249,7 @@ pub fn draw_connect_panel(
|
|||||||
ConnectionState::Error => "连接错误",
|
ConnectionState::Error => "连接错误",
|
||||||
};
|
};
|
||||||
let status_color = match conn_state {
|
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::Connecting => egui::Color32::from_rgb(200, 180, 60),
|
||||||
ConnectionState::Connected => egui::Color32::from_rgb(158, 184, 101),
|
ConnectionState::Connected => egui::Color32::from_rgb(158, 184, 101),
|
||||||
ConnectionState::Streaming => egui::Color32::from_rgb(100, 200, 255),
|
ConnectionState::Streaming => egui::Color32::from_rgb(100, 200, 255),
|
||||||
@@ -262,12 +265,12 @@ pub fn draw_connect_panel(
|
|||||||
let btn_fill = if is_connected {
|
let btn_fill = if is_connected {
|
||||||
egui::Color32::from_rgb(180, 60, 60)
|
egui::Color32::from_rgb(180, 60, 60)
|
||||||
} else {
|
} else {
|
||||||
ENGINEERING_DARK.accent
|
ONE_DARK_PRO.accent
|
||||||
};
|
};
|
||||||
let btn_stroke = if is_connected {
|
let btn_stroke = if is_connected {
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 80, 80))
|
egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 80, 80))
|
||||||
} else {
|
} else {
|
||||||
egui::Stroke::new(1.0, ENGINEERING_DARK.accent_hot)
|
egui::Stroke::new(1.0, ONE_DARK_PRO.accent_hot)
|
||||||
};
|
};
|
||||||
|
|
||||||
if ui
|
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
|
if ui
|
||||||
.add(
|
.add(
|
||||||
egui::Button::new(button_text)
|
egui::Button::new(button_text)
|
||||||
.fill(ENGINEERING_DARK.accent)
|
.fill(ONE_DARK_PRO.accent)
|
||||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.accent_hot))
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.accent_hot))
|
||||||
.min_size(egui::vec2(120.0, 30.0)),
|
.min_size(egui::vec2(120.0, 30.0)),
|
||||||
)
|
)
|
||||||
.clicked()
|
.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)),
|
egui::Image::new(source).fit_to_exact_size(egui::vec2(size.y - 8.0, size.y - 8.0)),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
.fill(ENGINEERING_DARK.panel_strong)
|
.fill(ONE_DARK_PRO.panel_strong)
|
||||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border))
|
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||||
.corner_radius(egui::CornerRadius::same(2))
|
.corner_radius(egui::CornerRadius::same(2))
|
||||||
.min_size(size);
|
.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) {
|
fn mode_button(ui: &mut egui::Ui, mode: &mut SerialMode, value: SerialMode, label: &'static str) {
|
||||||
let selected = *mode == value;
|
let selected = *mode == value;
|
||||||
let fill = if selected {
|
let fill = if selected {
|
||||||
ENGINEERING_DARK.accent
|
ONE_DARK_PRO.accent
|
||||||
} else {
|
} else {
|
||||||
ENGINEERING_DARK.panel_strong
|
ONE_DARK_PRO.panel_strong
|
||||||
};
|
};
|
||||||
let stroke = if selected {
|
let stroke = if selected {
|
||||||
ENGINEERING_DARK.accent_hot
|
ONE_DARK_PRO.accent_hot
|
||||||
} else {
|
} else {
|
||||||
ENGINEERING_DARK.border_soft
|
ONE_DARK_PRO.border_soft
|
||||||
};
|
};
|
||||||
|
|
||||||
if ui
|
if ui
|
||||||
@@ -749,8 +757,8 @@ fn center_panel_shell<R>(
|
|||||||
add_contents: impl FnOnce(&mut egui::Ui) -> R,
|
add_contents: impl FnOnce(&mut egui::Ui) -> R,
|
||||||
) -> egui::InnerResponse<R> {
|
) -> egui::InnerResponse<R> {
|
||||||
egui::Frame::new()
|
egui::Frame::new()
|
||||||
.fill(ENGINEERING_DARK.panel)
|
.fill(ONE_DARK_PRO.panel)
|
||||||
.corner_radius(egui::CornerRadius::same(ENGINEERING_DARK.radius))
|
.corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius))
|
||||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
.inner_margin(egui::Margin::symmetric(10, 8))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(width);
|
ui.set_width(width);
|
||||||
@@ -805,7 +813,7 @@ fn paint_integrated_handle(
|
|||||||
let center = rect.center();
|
let center = rect.center();
|
||||||
if !expanded {
|
if !expanded {
|
||||||
let fill = if hovered {
|
let fill = if hovered {
|
||||||
ENGINEERING_DARK.panel_strong
|
ONE_DARK_PRO.panel_strong
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(31, 41, 52)
|
egui::Color32::from_rgb(31, 41, 52)
|
||||||
};
|
};
|
||||||
@@ -818,11 +826,11 @@ fn paint_integrated_handle(
|
|||||||
ui.painter().add(egui::Shape::convex_polygon(
|
ui.painter().add(egui::Shape::convex_polygon(
|
||||||
points.clone(),
|
points.clone(),
|
||||||
fill,
|
fill,
|
||||||
egui::Stroke::new(1.0, ENGINEERING_DARK.border),
|
egui::Stroke::new(1.0, ONE_DARK_PRO.border),
|
||||||
));
|
));
|
||||||
ui.painter().line_segment(
|
ui.painter().line_segment(
|
||||||
[points[2], points[3]],
|
[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_center = egui::pos2(arrow_center_x, center.y);
|
||||||
let arrow_color = if hovered {
|
let arrow_color = if hovered {
|
||||||
ENGINEERING_DARK.accent_hot
|
ONE_DARK_PRO.accent_hot
|
||||||
} else {
|
} else {
|
||||||
ENGINEERING_DARK.text_dim
|
ONE_DARK_PRO.text_dim
|
||||||
};
|
};
|
||||||
ui.painter().line_segment(
|
ui.painter().line_segment(
|
||||||
[
|
[
|
||||||
@@ -858,13 +866,13 @@ fn paint_integrated_handle(
|
|||||||
fonts.layout_no_wrap(
|
fonts.layout_no_wrap(
|
||||||
label.to_owned(),
|
label.to_owned(),
|
||||||
egui::FontId::proportional(12.0),
|
egui::FontId::proportional(12.0),
|
||||||
ENGINEERING_DARK.text,
|
ONE_DARK_PRO.text,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
ui.painter().galley(
|
ui.painter().galley(
|
||||||
egui::pos2(rect.left() + 14.0, center.y - galley.size().y * 0.5 - 1.0),
|
egui::pos2(rect.left() + 14.0, center.y - galley.size().y * 0.5 - 1.0),
|
||||||
galley,
|
galley,
|
||||||
ENGINEERING_DARK.text,
|
ONE_DARK_PRO.text,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -876,13 +884,13 @@ fn paint_integrated_center_panel(
|
|||||||
handle_on_bottom: bool,
|
handle_on_bottom: bool,
|
||||||
) {
|
) {
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
let stroke = egui::Stroke::new(1.0, ENGINEERING_DARK.border);
|
let stroke = egui::Stroke::new(1.0, ONE_DARK_PRO.border);
|
||||||
let accent_stroke = egui::Stroke::new(1.2, ENGINEERING_DARK.border_soft);
|
let accent_stroke = egui::Stroke::new(1.2, ONE_DARK_PRO.border_soft);
|
||||||
let top = rect.top();
|
let top = rect.top();
|
||||||
let left = rect.left();
|
let left = rect.left();
|
||||||
let right = rect.right();
|
let right = rect.right();
|
||||||
let bottom = rect.bottom();
|
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_left = handle_rect.left() - 18.0;
|
||||||
let tab_right = handle_rect.right() + 18.0;
|
let tab_right = handle_rect.right() + 18.0;
|
||||||
let tab_recess = if handle_on_bottom {
|
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));
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user