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

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();
}
});
}