use eframe::egui; use crate::{ connection::{ConnectionManager, ConnectionState}, recording::Recorder, style::{ self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO, accent_text, dim_text, group_frame, layout, panel_frame, tag_button, }, utils::serial_enum, }; pub struct FloatingPanelState { pub visible: bool, default_pos: egui::Pos2, tag_pos: egui::Pos2, center_anim: f32, center_anim_target: bool, center_anim_last_time: Option, } pub struct ConfigPanelState { pub mode: SerialMode, pub port: String, pub baud_rate: u32, pub data_bits: u8, pub stop_bits: u8, pub parity: Parity, pub timeout_ms: u32, pub module_addr: u8, pub connected: bool, pub auto_reconnect: bool, pub manual_tx: String, pub model_path: String, } pub struct ConnectPanelState { pub mode: SerialMode, pub port: Vec, pub selected_port: String, pub duration: u8, pub manual: bool, pub rows: u8, pub cols: u8, pub connection: bool, } #[derive(Clone, Copy, PartialEq, Eq)] pub enum SerialMode { Finger, Hand, } #[derive(Clone, Copy, PartialEq, Eq)] pub enum Parity { None, Odd, Even, } pub enum IconButtonIcon<'a> { Font(&'a str), Png(egui::ImageSource<'a>), } impl FloatingPanelState { pub fn new(default_pos: [f32; 2], tag_pos: [f32; 2]) -> Self { Self { visible: true, default_pos: egui::pos2(default_pos[0], default_pos[1]), tag_pos: egui::pos2(tag_pos[0], tag_pos[1]), center_anim: 1.0, center_anim_target: true, center_anim_last_time: None, } } } impl Default for ConfigPanelState { fn default() -> Self { Self { mode: SerialMode::Finger, port: "COM3".to_owned(), baud_rate: 115_200, data_bits: 8, stop_bits: 1, parity: Parity::None, timeout_ms: 1000, module_addr: 1, connected: false, auto_reconnect: true, manual_tx: "01 03 00 00 00 02".to_owned(), model_path: "model/default.eskin".to_owned(), } } } impl Default for ConnectPanelState { fn default() -> Self { let port = serial_enum().unwrap_or_default(); let selected_port = port.first().cloned().unwrap_or_default(); Self { mode: SerialMode::Finger, port, selected_port, duration: 10, manual: false, rows: 12, cols: 7, connection: false, } } } pub fn draw_scene_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) { draw_floating_panel(ctx, panel, "场景", "scene_panel", |ui| { ui.horizontal(|ui| { ui.colored_label(dim_text(), "视图"); let _ = ui.selectable_label(true, "簇"); let _ = ui.selectable_label(false, "三角形"); }); ui.separator(); group_frame().show(ui, |ui| { ui.label("模型 / 材质 / 灯光"); ui.label("目标任务 64"); ui.label("缓存命中 100.0%"); }); }); } pub fn draw_connect_panel( ctx: &egui::Context, panel: &mut FloatingPanelState, config: &mut ConnectPanelState, connection: &ConnectionManager, recorder: &Recorder, export_path: &mut String, ) { let conn_state = connection.state(); let is_connected = matches!( conn_state, ConnectionState::Connected | ConnectionState::Streaming ); draw_center_floating_panel( ctx, panel, "connect_center_panel", layout::CENTER_PANEL_TOP, "连接", |ui| { ui.vertical(|ui| { // centered_mode_row(ui, &mut config.mode); ui.add_space(METRICS.row_gap); group_frame().show(ui, |ui| { ui.horizontal(|ui| { ui.add( egui::Image::new(egui::include_image!("../static/cpu.png")) .fit_to_exact_size(egui::vec2(64.0, 64.0)), ); ui.add_space(METRICS.item_gap); ui.vertical(|ui| { draw_connect_port_row(ui, config); ui.add_space(6.0); draw_connect_matrix_row(ui, config); }); }); }); ui.add_space(METRICS.row_gap); draw_connect_action_row(ui, conn_state, is_connected, config, connection); if is_connected { draw_recording_toolbar(ui, recorder, export_path); } }); }, ); } // fn centered_mode_row(ui: &mut egui::Ui, mode: &mut SerialMode) { // let button_width = 96.0; // let gap = METRICS.item_gap; // let total_width = button_width * 3.0 + gap * 2.0; // let left_space = ((ui.available_width() - total_width) * 0.5).max(0.0); // ui.horizontal(|ui| { // ui.add_space(left_space); // mode_button(ui, mode, SerialMode::Finger, "指尖模块"); // ui.add_space(gap); // mode_button(ui, mode, SerialMode::Hand, "手掌模块"); // // ui.add_space(gap); // // mode_button(ui, mode, SerialMode::Model, "模型"); // }); // } fn draw_connect_port_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) { ui.horizontal(|ui| { ui.label(style::field_label("串口")); egui::ComboBox::from_id_salt("connect_ports") .width(150.0) .selected_text(if config.selected_port.is_empty() { "无可用串口".to_owned() } else { config.selected_port.clone() }) .show_ui(ui, |ui| { for port in &config.port { ui.selectable_value(&mut config.selected_port, port.clone(), port.as_str()); } }); if ui .add(style::icon_button( egui::RichText::new("⟳").color(ONE_DARK_PRO.text).size(16.0), METRICS.icon_button, )) .on_hover_text("刷新串口") .clicked() { if let Ok(ports) = serial_enum() { if !ports.contains(&config.selected_port) { config.selected_port = ports.first().cloned().unwrap_or_default(); } config.port = ports; } } ui.add_space(METRICS.item_gap); ui.label(style::field_label("频率")); ui.add_sized( egui::vec2(72.0, METRICS.field_height), egui::DragValue::new(&mut config.duration).range(1..=120), ); }); } fn draw_connect_matrix_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) { ui.horizontal(|ui| { ui.checkbox(&mut config.manual, "手动矩阵"); ui.add_enabled_ui(config.manual, |ui| { ui.add_space(METRICS.item_gap); ui.label(style::field_label("行")); ui.add_sized( egui::vec2(56.0, METRICS.field_height), egui::DragValue::new(&mut config.rows).range(1..=64), ); ui.label(style::field_label("列")); ui.add_sized( egui::vec2(56.0, METRICS.field_height), egui::DragValue::new(&mut config.cols).range(1..=64), ); }); }); } fn draw_connect_action_row( ui: &mut egui::Ui, conn_state: ConnectionState, is_connected: bool, config: &ConnectPanelState, connection: &ConnectionManager, ) { ui.horizontal(|ui| { let status_text = match conn_state { ConnectionState::Disconnected => "未连接", ConnectionState::Connecting => "连接中...", ConnectionState::Connected => "已连接", ConnectionState::Streaming => "采集中", ConnectionState::Error => "连接错误", }; let status_color = match conn_state { ConnectionState::Disconnected => ONE_DARK_PRO.text_dim, ConnectionState::Connecting => style::status_color_warn(), ConnectionState::Connected => style::status_color_ok(), ConnectionState::Streaming => style::status_color_info(), ConnectionState::Error => style::status_color_error(), }; ui.colored_label(status_color, status_text); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let button = if is_connected { style::danger_button("断开") } else { style::primary_button("连接") }; if ui.add(button).clicked() { if is_connected { connection.disconnect(); } else if !config.selected_port.is_empty() { connection.connect( &config.selected_port, config.rows as u32, config.cols as u32, ); } } }); }); } pub fn draw_config_panel( ctx: &egui::Context, panel: &mut FloatingPanelState, config: &mut ConfigPanelState, ) -> Option { let mut changed_mode = None; draw_floating_panel(ctx, panel, "配置", "config_panel", |ui| { ui.set_min_width(560.0); if let Some(mode) = draw_mode_row(ui, config) { changed_mode = Some(mode); } // draw_mode_row(ui, config); ui.separator(); draw_connection_row(ui, config); ui.add_space(8.0); draw_serial_grid(ui, config); ui.add_space(8.0); draw_mode_body(ui, config); }); changed_mode } fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option { let mut changed_to = None; ui.horizontal(|ui| { ui.colored_label(dim_text(), "模式"); ui.add_space(12.0); if mode_button(ui, &mut config.mode, SerialMode::Finger, "指尖模块") { changed_to = Some(SerialMode::Finger) } if mode_button(ui, &mut config.mode, SerialMode::Hand, "手掌模块") { changed_to = Some(SerialMode::Hand) } // mode_button(ui, &mut config.mode, SerialMode::Model, "模型"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.checkbox(&mut config.auto_reconnect, "自动"); ui.colored_label(dim_text(), "重连"); }); }); changed_to } fn draw_connection_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) { ui.horizontal(|ui| { ui.add( egui::Image::new(egui::include_image!("../static/cpu.png")) .fit_to_exact_size(egui::vec2(72.0, 72.0)), ); ui.add_space(METRICS.item_gap); ui.vertical(|ui| { ui.label(style::value_text(format!("端口 {}", config.port))); ui.label(style::value_text(format!("波特率 {}", config.baud_rate))); let status = if config.connected { "已连接" } else { "未连接" }; let status_color = if config.connected { style::status_color_ok() } else { style::status_color_error() }; ui.colored_label(status_color, status); }); ui.add_space(22.0); let button_text = if config.connected { "断开" } else { "连接" }; if ui .add(if config.connected { style::danger_button(button_text) } else { style::primary_button(button_text) }) .clicked() { config.connected = !config.connected; } ui.add_space(18.0); ui.colored_label( if config.auto_reconnect { style::status_color_ok() } else { ONE_DARK_PRO.text_dim }, if config.auto_reconnect { "链路保护 开" } else { "链路保护 关" }, ); }); } fn draw_serial_grid(ui: &mut egui::Ui, config: &mut ConfigPanelState) { group_frame().show(ui, |ui| { egui::Grid::new("serial_config_grid") .num_columns(4) .spacing(egui::vec2(14.0, 7.0)) .striped(true) .show(ui, |ui| { ui.label(style::field_label("端口")); ui.add_sized( egui::vec2(110.0, METRICS.field_height), egui::TextEdit::singleline(&mut config.port), ); ui.label(style::field_label("波特率")); baud_combo(ui, config); ui.end_row(); ui.label(style::field_label("数据位")); ui.add_sized( egui::vec2(70.0, METRICS.field_height), egui::DragValue::new(&mut config.data_bits).range(5..=8), ); ui.label(style::field_label("校验")); parity_combo(ui, config); ui.end_row(); ui.label(style::field_label("停止位")); ui.add_sized( egui::vec2(70.0, METRICS.field_height), egui::DragValue::new(&mut config.stop_bits).range(1..=2), ); ui.label(style::field_label("超时")); ui.horizontal(|ui| { ui.add_sized( egui::vec2(84.0, METRICS.field_height), egui::DragValue::new(&mut config.timeout_ms) .range(50..=30_000) .speed(50), ); ui.colored_label(dim_text(), "毫秒"); }); ui.end_row(); }); }); } fn draw_mode_body(ui: &mut egui::Ui, config: &mut ConfigPanelState) { group_frame().show(ui, |ui| match config.mode { SerialMode::Finger => { ui.horizontal(|ui| { ui.label(style::field_label("模块地址")); ui.add_sized( egui::vec2(80.0, METRICS.field_height), egui::DragValue::new(&mut config.module_addr).range(1..=247), ); ui.add_space(16.0); if ui.add(tag_button("读取信息")).clicked() {} if ui.add(tag_button("探测")).clicked() {} }); ui.separator(); ui.horizontal(|ui| { ui.colored_label(dim_text(), "状态"); ui.label("就绪"); ui.colored_label(dim_text(), "接收"); ui.label("0 字节"); ui.colored_label(dim_text(), "发送"); ui.label("0 字节"); }); } SerialMode::Hand => { ui.horizontal(|ui| { ui.label(style::field_label("发送")); ui.add_sized( egui::vec2(300.0, METRICS.field_height), egui::TextEdit::singleline(&mut config.manual_tx), ); if ui.add(tag_button("发送")).clicked() {} if ui.add(tag_button("清空")).clicked() { config.manual_tx.clear(); } }); } // SerialMode::Model => { // ui.horizontal(|ui| { // ui.label(style::field_label("模型")); // ui.add_sized( // egui::vec2(300.0, METRICS.field_height), // egui::TextEdit::singleline(&mut config.model_path), // ); // if ui.add(tag_button("加载")).clicked() {} // if ui.add(tag_button("运行")).clicked() {} // }); // } }); } pub fn icon_button<'a>( ui: &mut egui::Ui, icon: IconButtonIcon<'a>, tooltip: impl Into, ) -> egui::Response { icon_button_sized(ui, icon, tooltip, egui::vec2(28.0, 24.0)) } pub fn icon_button_sized<'a>( ui: &mut egui::Ui, icon: IconButtonIcon<'a>, tooltip: impl Into, size: egui::Vec2, ) -> egui::Response { let button = match icon { IconButtonIcon::Font(icon) => egui::Button::new( egui::RichText::new(icon) .color(egui::Color32::WHITE) .size(size.y - 8.0), ), IconButtonIcon::Png(source) => egui::Button::image( egui::Image::new(source).fit_to_exact_size(egui::vec2(size.y - 8.0, size.y - 8.0)), ), } .fill(ONE_DARK_PRO.panel_strong) .stroke(egui::Stroke::new(1.0, ONE_DARK_PRO.border)) .corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius)) .min_size(size); ui.add(button).on_hover_text(tooltip) } fn mode_button( ui: &mut egui::Ui, mode: &mut SerialMode, value: SerialMode, label: &'static str, ) -> bool { let clicked = ui.add(style::mode_button(label, *mode == value)).clicked(); if clicked && *mode != value { *mode = value; true } else { false } } fn baud_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) { egui::ComboBox::from_id_salt("serial_baud_rate") .width(110.0) .selected_text(config.baud_rate.to_string()) .show_ui(ui, |ui| { for baud_rate in [ 9_600, 19_200, 38_400, 57_600, 115_200, 230_400, 460_800, 921_600, ] { ui.selectable_value(&mut config.baud_rate, baud_rate, baud_rate.to_string()); } }); } fn parity_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) { egui::ComboBox::from_id_salt("serial_parity") .width(110.0) .selected_text(match config.parity { Parity::None => "无", Parity::Odd => "奇", Parity::Even => "偶", }) .show_ui(ui, |ui| { ui.selectable_value(&mut config.parity, Parity::None, "无"); ui.selectable_value(&mut config.parity, Parity::Odd, "奇"); ui.selectable_value(&mut config.parity, Parity::Even, "偶"); }); } pub fn draw_stats_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) { draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| { ui.horizontal(|ui| { ui.colored_label(accent_text(), "0.030"); ui.label(style::subtle_text("81m:51s")); }); ui.separator(); group_frame().show(ui, |ui| { ui.label(style::field_label("帧率 / GPU 信息")); ui.label(style::value_text("边界 589.0us")); ui.label(style::value_text("簇 12.8ms")); }); }); } fn draw_floating_panel( ctx: &egui::Context, panel: &mut FloatingPanelState, title: &'static str, id: &'static str, add_contents: impl FnOnce(&mut egui::Ui), ) { if panel.visible { let mut open = true; let mut hide_requested = false; let mut window_rect = None; let window_response = egui::Window::new(title) .id(egui::Id::new(id)) .open(&mut open) .default_pos(panel.default_pos) .title_bar(false) .resizable(true) .frame(panel_frame(ctx)) .show(ctx, |ui| { ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); ui.horizontal(|ui| { if ui.add(tag_button("隐藏")).clicked() { hide_requested = true; } ui.add_space(6.0); ui.label(style::panel_title(title)); }); ui.separator(); add_contents(ui); }); if let Some(response) = window_response { window_rect = Some(response.response.rect); } if hide_requested { if let Some(rect) = window_rect { let screen = ctx.content_rect(); let tag_size = egui::vec2(86.0, 22.0); let distance_to_left = rect.left(); let distance_to_right = screen.right() - rect.right(); let x = if distance_to_left <= distance_to_right { screen.left() } else { screen.right() - tag_size.x }; let y = rect.top().clamp(screen.top(), screen.bottom() - tag_size.y); panel.tag_pos = egui::pos2(x, y); } } panel.visible = open && !hide_requested; } // else { // let response = egui::Area::new(egui::Id::new(format!("{id}_tag"))) // .current_pos(panel.tag_pos) // .movable(true) // .order(egui::Order::Foreground) // .show(ctx, |ui| { // ui.set_min_width(86.0); // if ui // .add(tag_button(format!("▸ {title}")).min_size(egui::vec2(86.0, 22.0))) // .clicked() // { // panel.visible = true; // } // }); // panel.tag_pos = response.response.rect.min; // } } fn draw_center_floating_panel( ctx: &egui::Context, panel: &mut FloatingPanelState, id: &'static str, top_offset: f32, collapsed_label: &'static str, add_contents: impl FnOnce(&mut egui::Ui), ) { const PANEL_WIDTH: f32 = 520.0; const SLIDE_DISTANCE: f32 = 18.0; let anim = advance_center_panel_anim(ctx, panel); let eased = ease_in_out(anim); if !panel.visible || anim < 0.28 { let handle_y = top_offset - (1.0 - eased) * 2.0; egui::Area::new(egui::Id::new(format!("{id}_handle"))) .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, handle_y)) .order(egui::Order::Tooltip) .show(ctx, |ui| { let handle_rect = ui .allocate_exact_size(egui::vec2(112.0, 24.0), egui::Sense::hover()) .0; if integrated_handle(ui, handle_rect, collapsed_label, false, "打开连接面板") .clicked() { panel.visible = true; } }); } if anim <= 0.02 { return; } egui::Area::new(egui::Id::new(id)) .anchor( egui::Align2::CENTER_TOP, egui::vec2(0.0, top_offset - (1.0 - eased) * SLIDE_DISTANCE), ) .order(egui::Order::Tooltip) .show(ctx, |ui| { let response = center_panel_shell(ui, PANEL_WIDTH, |ui| { ui.set_width(PANEL_WIDTH); add_contents(ui); ui.add_space(6.0); let handle_rect = allocate_center_panel_handle(ui); if integrated_handle(ui, handle_rect, "", true, "收起连接面板").clicked() { panel.visible = false; } handle_rect }); paint_integrated_center_panel(ui, response.response.rect, response.inner, true); }); } fn advance_center_panel_anim(ctx: &egui::Context, panel: &mut FloatingPanelState) -> f32 { let target = if panel.visible { 1.0 } else { 0.0 }; let now = ctx.input(|input| input.time); let delta_time = panel .center_anim_last_time .map(|last_time| (now - last_time) as f32) .unwrap_or(1.0 / 60.0) .clamp(0.0, 0.05); panel.center_anim_last_time = Some(now); panel.center_anim_target = panel.visible; let speed = 7.5; let step = speed * delta_time; if panel.center_anim < target { panel.center_anim = (panel.center_anim + step).min(target); } else if panel.center_anim > target { panel.center_anim = (panel.center_anim - step).max(target); } if (panel.center_anim - target).abs() > 0.001 { ctx.request_repaint(); } panel.center_anim } fn ease_in_out(t: f32) -> f32 { let t = t.clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) } fn center_panel_shell( ui: &mut egui::Ui, width: f32, add_contents: impl FnOnce(&mut egui::Ui) -> R, ) -> egui::InnerResponse { style::center_panel_frame().show(ui, |ui| { ui.set_width(width); add_contents(ui) }) } fn allocate_center_panel_handle(ui: &mut egui::Ui) -> egui::Rect { let handle_width = 112.0; let handle_height = 24.0; let left_space = ((ui.available_width() - handle_width) * 0.5).max(0.0); let mut handle_rect = egui::Rect::NOTHING; ui.horizontal(|ui| { ui.add_space(left_space); let (rect, _) = ui.allocate_exact_size( egui::vec2(handle_width, handle_height), egui::Sense::hover(), ); handle_rect = rect; }); handle_rect } fn integrated_handle( ui: &mut egui::Ui, rect: egui::Rect, label: &str, expanded: bool, tooltip: &'static str, ) -> egui::Response { let response = ui .interact( rect.expand2(egui::vec2(12.0, 5.0)), ui.id().with(("center_panel_handle", expanded)), egui::Sense::click(), ) .on_hover_text(tooltip); paint_integrated_handle(ui, rect, label, expanded, response.hovered()); response } fn paint_integrated_handle( ui: &mut egui::Ui, rect: egui::Rect, label: &str, expanded: bool, hovered: bool, ) { let center = rect.center(); if !expanded { let fill = if hovered { ONE_DARK_PRO.panel_strong } else { egui::Color32::from_rgb(31, 41, 52) }; let points = vec![ egui::pos2(rect.left() - 12.0, rect.top() - 1.0), egui::pos2(rect.right() + 12.0, rect.top() - 1.0), egui::pos2(rect.right() + 3.0, rect.bottom() + 8.0), egui::pos2(rect.left() - 3.0, rect.bottom() + 8.0), ]; ui.painter().add(egui::Shape::convex_polygon( points.clone(), fill, egui::Stroke::new(1.0, ONE_DARK_PRO.border), )); ui.painter().line_segment( [points[2], points[3]], egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft), ); } let arrow = if expanded { -1.0 } else { 1.0 }; let arrow_center_x = if label.is_empty() { center.x } else { rect.right() - 18.0 }; let arrow_center = egui::pos2(arrow_center_x, center.y); let arrow_color = if hovered { ONE_DARK_PRO.accent_hot } else { ONE_DARK_PRO.text_dim }; ui.painter().line_segment( [ arrow_center + egui::vec2(-6.0, -2.0 * arrow), arrow_center + egui::vec2(0.0, 4.0 * arrow), ], egui::Stroke::new(1.7, arrow_color), ); ui.painter().line_segment( [ arrow_center + egui::vec2(6.0, -2.0 * arrow), arrow_center + egui::vec2(0.0, 4.0 * arrow), ], egui::Stroke::new(1.7, arrow_color), ); if !label.is_empty() { let galley = ui.fonts_mut(|fonts| { fonts.layout_no_wrap( label.to_owned(), egui::FontId::proportional(12.0), ONE_DARK_PRO.text, ) }); ui.painter().galley( egui::pos2(rect.left() + 14.0, center.y - galley.size().y * 0.5 - 1.0), galley, ONE_DARK_PRO.text, ); } } fn paint_integrated_center_panel( ui: &egui::Ui, rect: egui::Rect, handle_rect: egui::Rect, handle_on_bottom: bool, ) { let painter = ui.painter(); 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 = 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 { handle_rect.top() - 2.0 } else { handle_rect.bottom() + 2.0 }; if handle_on_bottom { painter.line_segment( [ egui::pos2(left + radius, top), egui::pos2(right - radius, top), ], stroke, ); } else { painter.line_segment( [egui::pos2(left + radius, top), egui::pos2(tab_left, top)], stroke, ); painter.line_segment( [egui::pos2(tab_right, top), egui::pos2(right - radius, top)], stroke, ); } painter.line_segment( [ egui::pos2(left, top + radius), egui::pos2(left, bottom - radius), ], stroke, ); painter.line_segment( [ egui::pos2(right, top + radius), egui::pos2(right, bottom - radius), ], stroke, ); if handle_on_bottom { painter.line_segment( [ egui::pos2(left + radius, bottom), egui::pos2(tab_left, bottom), ], stroke, ); painter.line_segment( [ egui::pos2(tab_right, bottom), egui::pos2(right - radius, bottom), ], stroke, ); } else { painter.line_segment( [ egui::pos2(left + radius, bottom), egui::pos2(right - radius, bottom), ], stroke, ); } let tab_points = if handle_on_bottom { vec![ egui::pos2(tab_left, bottom), egui::pos2(handle_rect.left() - 8.0, tab_recess), egui::pos2(handle_rect.right() + 8.0, tab_recess), egui::pos2(tab_right, bottom), ] } else { vec![ egui::pos2(tab_left, top), egui::pos2(handle_rect.left() - 8.0, tab_recess), egui::pos2(handle_rect.right() + 8.0, tab_recess), egui::pos2(tab_right, top), ] }; painter.add(egui::Shape::line(tab_points, accent_stroke)); } // ── Signal Chart (sparkline) ─────────────────────────────────────────── /// Draw a mini sparkline chart with current/min/max labels. pub fn draw_signal_chart(ui: &mut egui::Ui, values: &[f32], label: &str, color: egui::Color32) { let chart_height = 48.0; let chart_width = ui.available_width().min(200.0); group_frame().show(ui, |ui| { ui.horizontal(|ui| { ui.colored_label(color, label); if let (Some(&last), Some(&min), Some(&max)) = ( values.last(), values .iter() .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)), values .iter() .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)), ) { ui.colored_label(ONE_DARK_PRO.text, format!("{:.0}", last)); ui.colored_label(ONE_DARK_PRO.text_dim, format!("↓{:.0} ↑{:.0}", min, max)); } }); let (rect, _response) = ui.allocate_exact_size(egui::vec2(chart_width, chart_height), egui::Sense::hover()); if values.len() < 2 { return; } let painter = ui.painter_at(rect); painter.rect_filled(rect, egui::CornerRadius::same(2), ONE_DARK_PRO.panel_deep); let min_val = values .iter() .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .copied() .unwrap_or(0.0); let max_val = values .iter() .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .copied() .unwrap_or(1.0); let range = (max_val - min_val).max(1.0); let points: Vec = values .iter() .enumerate() .map(|(i, &v)| { let x = rect.left() + (i as f32 / (values.len() - 1) as f32) * rect.width(); let y = rect.bottom() - ((v - min_val) / range) * rect.height(); egui::pos2(x, y) }) .collect(); painter.add(egui::Shape::line(points, egui::Stroke::new(1.5, color))); }); } // ── Recording Toolbar ────────────────────────────────────────────────── /// Draw recording controls inside the connect panel. pub fn draw_recording_toolbar(ui: &mut egui::Ui, recorder: &Recorder, export_path: &mut String) { let is_recording = recorder.is_recording(); let frame_count = recorder.frame_count(); let duration_ms = recorder.duration_ms(); ui.separator(); ui.horizontal(|ui| { ui.colored_label(ACCENT_RED, "● REC"); ui.colored_label( ONE_DARK_PRO.text, format!("{} 帧 | {:.1}s", frame_count, duration_ms as f64 / 1000.0), ); }); ui.horizontal(|ui| { // Full recording let rec_btn = if is_recording { tag_button("⏹ 停止") } else { style::accent_button( egui::RichText::new("● 全量录制").color(egui::Color32::WHITE), ACCENT_RED, ) }; if ui.add(rec_btn).clicked() { if is_recording { let _ = recorder.stop_recording(); } else { let _ = recorder.start_full_recording(); } } ui.add_space(4.0); // Snapshot recording let snap_btn = if is_recording { tag_button("⏹ 快照停止") } else { style::accent_button( egui::RichText::new("✂ 快照录制").color(egui::Color32::WHITE), ACCENT_ORANGE, ) }; if ui.add(snap_btn).clicked() { if is_recording { let _ = recorder.stop_recording(); } else { let _ = recorder.start_snapshot_recording(); } } ui.add_space(4.0); // Pause/Resume if is_recording { if ui.add(tag_button("⏸ 暂停")).clicked() { let _ = recorder.pause_recording(); } } ui.add_space(8.0); // Export if ui .add(style::accent_button( egui::RichText::new("📤 导出CSV").color(egui::Color32::WHITE), ACCENT_BLUE, )) .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(style::accent_button( egui::RichText::new("📥 导入CSV").color(egui::Color32::WHITE), ACCENT_GREEN, )) .clicked() { let import_path = export_path.clone(); if let Err(e) = recorder.import_csv(&import_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).max(160.0), METRICS.field_height, ), 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.label(style::field_label("矩阵尺寸")); ui.add_space(METRICS.item_gap); ui.label(style::value_text("行")); ui.add_sized( egui::vec2(56.0, METRICS.field_height), egui::DragValue::new(&mut config.rows).range(1..=128), ); ui.label(style::value_text("列")); ui.add_sized( egui::vec2(56.0, METRICS.field_height), egui::DragValue::new(&mut config.cols).range(1..=128), ); }); ui.add_space(METRICS.item_gap); ui.horizontal(|ui| { ui.label(style::field_label("预设")); 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(METRICS.item_gap); ui.horizontal(|ui| { ui.label(style::field_label("色域范围")); ui.add_space(METRICS.item_gap); ui.label(style::value_text("最小")); ui.add_sized( egui::vec2(72.0, METRICS.field_height), egui::DragValue::new(&mut config.color_min) .range(0.0..=10000.0) .speed(100.0), ); ui.label(style::value_text("最大")); ui.add_sized( egui::vec2(72.0, METRICS.field_height), egui::DragValue::new(&mut config.color_max) .range(1.0..=10000.0) .speed(100.0), ); }); ui.add_space(METRICS.row_gap); if ui.add(tag_button("重置默认")).clicked() { *config = MatrixConfigState::default(); } }); } pub fn panel_restore_item(ui: &mut egui::Ui, title: &'static str, panel: &mut FloatingPanelState) { if panel.visible { ui.add_enabled(false, egui::Button::new(format!("{title} 已显示"))); } else if ui.button(format!("显示 {title}")).clicked() { panel.visible = true; ui.close(); } }