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:
343
src/ui.rs
343
src/ui.rs
@@ -2,7 +2,8 @@ use eframe::egui;
|
||||
|
||||
use crate::{
|
||||
connection::{ConnectionManager, ConnectionState},
|
||||
theme::{ENGINEERING_DARK, accent_text, dim_text, group_frame, panel_frame, tag_button},
|
||||
recording::Recorder,
|
||||
theme::{ONE_DARK_PRO, ACCENT_BLUE, ACCENT_CYAN, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, accent_text, dim_text, group_frame, panel_frame, tag_button},
|
||||
utils::serial_enum,
|
||||
};
|
||||
|
||||
@@ -130,6 +131,8 @@ pub fn draw_connect_panel(
|
||||
panel: &mut FloatingPanelState,
|
||||
config: &mut ConnectPanelState,
|
||||
connection: &ConnectionManager,
|
||||
recorder: &Recorder,
|
||||
export_path: &mut String,
|
||||
) {
|
||||
let conn_state = connection.state();
|
||||
let is_connected = matches!(
|
||||
@@ -167,7 +170,7 @@ pub fn draw_connect_panel(
|
||||
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(ENGINEERING_DARK.text, "串口");
|
||||
ui.colored_label(ONE_DARK_PRO.text, "串口");
|
||||
egui::ComboBox::from_id_salt("connect_ports")
|
||||
.width(130.0)
|
||||
.selected_text(if config.selected_port.is_empty() {
|
||||
@@ -188,8 +191,8 @@ pub fn draw_connect_panel(
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new("⟳")
|
||||
.fill(ENGINEERING_DARK.panel_strong)
|
||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft))
|
||||
.fill(ONE_DARK_PRO.panel_strong)
|
||||
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft))
|
||||
.min_size(egui::vec2(24.0, 20.0)),
|
||||
)
|
||||
.on_hover_text("刷新串口")
|
||||
@@ -218,12 +221,12 @@ pub fn draw_connect_panel(
|
||||
|
||||
ui.add_enabled_ui(config.manual, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(ENGINEERING_DARK.text_dim, "行");
|
||||
ui.colored_label(ONE_DARK_PRO.text_dim, "行");
|
||||
ui.add_sized(
|
||||
egui::vec2(48.0, 20.0),
|
||||
egui::DragValue::new(&mut config.rows).range(1..=64),
|
||||
);
|
||||
ui.colored_label(ENGINEERING_DARK.text_dim, "列");
|
||||
ui.colored_label(ONE_DARK_PRO.text_dim, "列");
|
||||
ui.add_sized(
|
||||
egui::vec2(48.0, 20.0),
|
||||
egui::DragValue::new(&mut config.cols).range(1..=64),
|
||||
@@ -246,7 +249,7 @@ pub fn draw_connect_panel(
|
||||
ConnectionState::Error => "连接错误",
|
||||
};
|
||||
let status_color = match conn_state {
|
||||
ConnectionState::Disconnected => ENGINEERING_DARK.text_dim,
|
||||
ConnectionState::Disconnected => ONE_DARK_PRO.text_dim,
|
||||
ConnectionState::Connecting => egui::Color32::from_rgb(200, 180, 60),
|
||||
ConnectionState::Connected => egui::Color32::from_rgb(158, 184, 101),
|
||||
ConnectionState::Streaming => egui::Color32::from_rgb(100, 200, 255),
|
||||
@@ -262,12 +265,12 @@ pub fn draw_connect_panel(
|
||||
let btn_fill = if is_connected {
|
||||
egui::Color32::from_rgb(180, 60, 60)
|
||||
} else {
|
||||
ENGINEERING_DARK.accent
|
||||
ONE_DARK_PRO.accent
|
||||
};
|
||||
let btn_stroke = if is_connected {
|
||||
egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 80, 80))
|
||||
} else {
|
||||
egui::Stroke::new(1.0, ENGINEERING_DARK.accent_hot)
|
||||
egui::Stroke::new(1.0, ONE_DARK_PRO.accent_hot)
|
||||
};
|
||||
|
||||
if ui
|
||||
@@ -292,6 +295,11 @@ pub fn draw_connect_panel(
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Recording toolbar (visible when connected)
|
||||
if is_connected {
|
||||
draw_recording_toolbar(ui, recorder, export_path);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -361,8 +369,8 @@ fn draw_connection_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(button_text)
|
||||
.fill(ENGINEERING_DARK.accent)
|
||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.accent_hot))
|
||||
.fill(ONE_DARK_PRO.accent)
|
||||
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.accent_hot))
|
||||
.min_size(egui::vec2(120.0, 30.0)),
|
||||
)
|
||||
.clicked()
|
||||
@@ -504,8 +512,8 @@ pub fn icon_button_sized<'a>(
|
||||
egui::Image::new(source).fit_to_exact_size(egui::vec2(size.y - 8.0, size.y - 8.0)),
|
||||
),
|
||||
}
|
||||
.fill(ENGINEERING_DARK.panel_strong)
|
||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border))
|
||||
.fill(ONE_DARK_PRO.panel_strong)
|
||||
.stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border))
|
||||
.corner_radius(egui::CornerRadius::same(2))
|
||||
.min_size(size);
|
||||
|
||||
@@ -515,14 +523,14 @@ pub fn icon_button_sized<'a>(
|
||||
fn mode_button(ui: &mut egui::Ui, mode: &mut SerialMode, value: SerialMode, label: &'static str) {
|
||||
let selected = *mode == value;
|
||||
let fill = if selected {
|
||||
ENGINEERING_DARK.accent
|
||||
ONE_DARK_PRO.accent
|
||||
} else {
|
||||
ENGINEERING_DARK.panel_strong
|
||||
ONE_DARK_PRO.panel_strong
|
||||
};
|
||||
let stroke = if selected {
|
||||
ENGINEERING_DARK.accent_hot
|
||||
ONE_DARK_PRO.accent_hot
|
||||
} else {
|
||||
ENGINEERING_DARK.border_soft
|
||||
ONE_DARK_PRO.border_soft
|
||||
};
|
||||
|
||||
if ui
|
||||
@@ -749,8 +757,8 @@ fn center_panel_shell<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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user