use eframe::egui; use crate::{ connection::{ConnectionManager, ConnectionState}, force::HudSpatialForce, recording::Recorder, serial_core::serial::{SerialIoStats, SerialProtocol}, style::{ self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO, dim_text, group_frame, layout::{self}, panel_frame, rich_tag_button, 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 available_ports: Vec, 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, // Finger3D, Hand, } impl SerialMode { pub fn baud_rate(self) -> u32 { match self { SerialMode::Finger => 921_600, SerialMode::Hand => 1_152_000, } } pub fn protocol(self) -> SerialProtocol { match self { SerialMode::Finger => SerialProtocol::TactileA, SerialMode::Hand => SerialProtocol::HandGateway, } } } #[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 { let available_ports = serial_enum().unwrap_or_default(); let port = available_ports .first() .cloned() .unwrap_or_else(|| "无可用串口".to_owned()); Self { mode: SerialMode::Finger, port, available_ports, baud_rate: 921_600, data_bits: 8, stop_bits: 1, parity: Parity::None, timeout_ms: 1000, module_addr: 1, connected: false, auto_reconnect: false, 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, } } } const PANEL_VIEWPORT_MARGIN: f32 = 12.0; fn viewport_panel_rect(ctx: &egui::Context) -> egui::Rect { ctx.content_rect().shrink(PANEL_VIEWPORT_MARGIN) } fn responsive_panel_width(ctx: &egui::Context, preferred: f32, min_width: f32) -> f32 { let screen = ctx.content_rect(); let available = (screen.width() - PANEL_VIEWPORT_MARGIN * 2.0).max(240.0); let side_cap = (screen.width() * 0.44).max(min_width.min(available)); preferred .min(side_cap) .min(available) .max(min_width.min(available)) } fn responsive_panel_height(ctx: &egui::Context, preferred: f32, min_height: f32) -> f32 { let screen = ctx.content_rect(); let available = (screen.height() - layout::TITLE_BAR_HEIGHT - PANEL_VIEWPORT_MARGIN * 2.0) .max(min_height.min(screen.height())); let height_cap = (screen.height() * 0.72).max(min_height.min(available)); preferred .min(height_cap) .min(available) .max(min_height.min(available)) } fn responsive_panel_pos( ctx: &egui::Context, preferred: egui::Pos2, panel_width: f32, ) -> egui::Pos2 { let rect = viewport_panel_rect(ctx); let min_y = rect.top() + layout::TITLE_BAR_HEIGHT + PANEL_VIEWPORT_MARGIN; let max_x = (rect.right() - panel_width).max(rect.left()); let preferred_right_side = preferred.x > rect.center().x; let x = if preferred_right_side { max_x } else { preferred.x.clamp(rect.left(), max_x) }; let y = preferred.y.clamp(min_y, rect.bottom()); egui::pos2(x, y) } fn narrow_panel(ui: &egui::Ui) -> bool { ui.available_width() < 420.0 } pub fn draw_scene_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) { draw_floating_panel(ctx, panel, "场景", "scene_panel", 320.0, 260.0, |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(8.0); // draw_connect_matrix_row(ui, config); }); }); }); ui.add_space(METRICS.row_gap); draw_connect_action_row(ui, conn_state, is_connected, config, connection, recorder); 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_wrapped(|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() && 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_wrapped(|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, recorder: &Recorder, ) { ui.horizontal_wrapped(|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, config.mode.baud_rate(), config.mode.protocol(), recorder.clone(), ); } } }); }); } #[allow(clippy::too_many_arguments)] pub fn draw_config_panel( ctx: &egui::Context, panel: &mut FloatingPanelState, config: &mut ConfigPanelState, connection: &ConnectionManager, recorder: &Recorder, _state: SerialIoStats, _sample_rate_hz: f32, _render_rate_hz: f32, ) -> Option { let mut changed_mode = None; let conn_state = connection.state(); let screen = ctx.content_rect(); let bar_rect = egui::Rect::from_min_size( screen.left_top() + egui::vec2(0.0, layout::TITLE_BAR_HEIGHT), egui::vec2(screen.width(), layout::CONFIG_BAR_HEIGHT), ); config.connected = matches!( conn_state, ConnectionState::Connected | ConnectionState::Streaming ); panel.visible = true; egui::Area::new(egui::Id::new("config_panel_bar")) .fixed_pos(bar_rect.min) .order(egui::Order::Foreground) .show(ctx, |ui| { ui.set_min_size(bar_rect.size()); ui.set_max_size(bar_rect.size()); config_bar_frame().show(ui, |ui| { ui.set_min_width(bar_rect.width()); ui.set_max_width(bar_rect.width()); ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); ui.horizontal(|ui| { ui.label(style::panel_title("配置面板")); ui.add_space(12.0); if let Some(mode) = draw_config_bar_mode(ui, config) { changed_mode = Some(mode); } ui.add_space(12.0); draw_config_bar_connection(ui, config, connection, recorder, conn_state); // ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // draw_conf // }) }) }) }); // let stats = connection.stats(); // let panel_width = responsive_panel_width(ctx, 560.0, 340.0); // config.connected = matches!( // conn_state, // ConnectionState::Connected | ConnectionState::Streaming // ); // draw_floating_panel(ctx, panel, "配置", "config_panel", 560.0, 340.0, |ui| { // ui.set_min_width(panel_width); // ui.set_max_width(panel_width); // 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, connection, recorder, conn_state); // ui.add_space(8.0); // // Legacy serial parameter grid is intentionally kept commented for future hardware: // // draw_serial_grid(ui, config); // // ui.add_space(8.0); // draw_mode_body(ui, config, conn_state, stats); // }); changed_mode } fn config_bar_frame() -> egui::Frame { egui::Frame::new() .fill(ONE_DARK_PRO.panel_deep) .inner_margin(egui::Margin::symmetric(8, 8)) } fn draw_config_bar_mode(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option { let mut changed_to = None; ui.horizontal_wrapped(|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, "270°展开") { changed_to = Some(SerialMode::Hand) } // mode_button(ui, &mut config.mode, SerialMode::Model, "模型"); // Legacy reconnect controls. Current sensors run fixed 921600 baud without auto-reconnect UI. // 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_config_bar_connection( ui: &mut egui::Ui, config: &mut ConfigPanelState, connection: &ConnectionManager, recorder: &Recorder, conn_state: ConnectionState, ) { ui.colored_label(dim_text(), "端口"); egui::ComboBox::from_id_salt("config_bar_ports") .selected_text("config_bar_ports") .width(170.0) .selected_text(if config.port.is_empty() { "无可用串口".to_owned() } else { config.port.clone() }) .show_ui(ui, |ui| { for port in &config.available_ports { ui.selectable_value(&mut config.port, port.clone(), port.as_str()); } }); if ui .add(style::icon_button( egui::RichText::new("\u{f021}") .color(ONE_DARK_PRO.text) .size(16.0), egui::vec2(30.0, METRICS.field_height), )) .on_hover_text("刷新串口") .clicked() { refresh_config_ports(config); } ui.add_space(4.0); ui.colored_label(dim_text(), "波特率"); ui.label(style::value_text(config.baud_rate.to_string())); ui.add_space(4.0); ui.colored_label(dim_text(), "状态"); ui.colored_label( connection_status_color(conn_state), connection_status_text(conn_state), ); ui.add_space(6.0); let is_connected = matches!( conn_state, ConnectionState::Connected | ConnectionState::Streaming ); let button_text = if is_connected { "\u{f127}" } else { "\u{f0c1}" }; if ui .add_sized( egui::vec2(88.0, METRICS.button_height), if is_connected { style::danger_button(button_text) } else { style::primary_button(button_text) }, ) .clicked() { if is_connected { connection.disconnect(); } else if !config.port.is_empty() { connection.connect( &config.port, 12, 7, config.mode.baud_rate(), config.mode.protocol(), recorder.clone(), ); } } } fn draw_connection_row( ui: &mut egui::Ui, config: &mut ConfigPanelState, connection: &ConnectionManager, recorder: &Recorder, conn_state: ConnectionState, ) { let is_narrow = narrow_panel(ui); let draw_port_status = |ui: &mut egui::Ui, config: &mut ConfigPanelState| { ui.horizontal_wrapped(|ui| { ui.label(style::field_label("端口")); egui::ComboBox::from_id_salt("config_ports") .width(ui.available_width().clamp(104.0, 150.0)) .selected_text(if config.port.is_empty() { "无可用串口".to_owned() } else { config.port.clone() }) .show_ui(ui, |ui| { for port in &config.available_ports { ui.selectable_value(&mut config.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() { refresh_config_ports(config); } }); ui.label(style::value_text(format!("波特率 {}", config.baud_rate))); ui.colored_label( connection_status_color(conn_state), connection_status_text(conn_state), ); }; let draw_connect_button = |ui: &mut egui::Ui, config: &ConfigPanelState| { let is_connected = matches!( conn_state, ConnectionState::Connected | ConnectionState::Streaming ); let button_text = if is_connected { "断开" } else { "连接" }; if ui .add(if is_connected { style::danger_button(button_text) } else { style::primary_button(button_text) }) .clicked() { if is_connected { connection.disconnect(); } else if !config.port.is_empty() { connection.connect( &config.port, 12, 7, config.mode.baud_rate(), config.mode.protocol(), recorder.clone(), ); } } }; if is_narrow { ui.vertical(|ui| { draw_port_status(ui, config); ui.add_space(METRICS.item_gap); draw_connect_button(ui, config); }); } else { 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| { draw_port_status(ui, config); }); ui.add_space(22.0); draw_connect_button(ui, config); }); } // Legacy reconnect/link-protection indicator: // 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, conn_state: ConnectionState, stats: SerialIoStats, ) { group_frame().show(ui, |ui| match config.mode { SerialMode::Finger => { // Legacy module-address controls for older devices: // 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() {} // }); draw_status_bytes_row(ui, conn_state, stats); } SerialMode::Hand => { // Legacy manual-TX controls for older hand-module debugging: // 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(); // } // }); draw_status_bytes_row(ui, conn_state, stats); } // 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() {} // }); // } }); } fn draw_status_bytes_row(ui: &mut egui::Ui, conn_state: ConnectionState, stats: SerialIoStats) { ui.horizontal_wrapped(|ui| { ui.colored_label(dim_text(), "状态"); ui.label(connection_status_text(conn_state)); ui.colored_label(dim_text(), "接收"); ui.label(format!("{} 字节", stats.rx_bytes)); ui.colored_label(dim_text(), "发送"); ui.label(format!("{} 字节", stats.tx_bytes)); }); } fn refresh_config_ports(config: &mut ConfigPanelState) { if let Ok(ports) = serial_enum() { if !ports.contains(&config.port) { config.port = ports.first().cloned().unwrap_or_default(); } config.available_ports = ports; } } fn connection_status_text(state: ConnectionState) -> &'static str { match state { ConnectionState::Disconnected => "未连接", ConnectionState::Connecting => "连接中...", ConnectionState::Connected => "已连接", ConnectionState::Streaming => "采集中", ConnectionState::Error => "连接错误", } } fn connection_status_color(state: ConnectionState) -> egui::Color32 { match state { ConnectionState::Disconnected => style::status_color_error(), ConnectionState::Connecting => style::status_color_warn(), ConnectionState::Connected | ConnectionState::Streaming => style::status_color_ok(), ConnectionState::Error => style::status_color_error(), } } 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_f32, 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, 1_152_000, ] { 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, force_history: &[f32], _spatial_force: Option, ) { const PREFERRED_PANEL_WIDTH: f32 = 380.0; const PREFERRED_PANEL_HEIGHT: f32 = 340.0; const PANEL_OUTSIDE_GAP: f32 = 18.0; let force_active = has_recent_resultant_force(force_history); let target_visible = panel.visible && force_active; let anim = ctx.animate_bool(egui::Id::new("stats_panel_force_enter"), target_visible); if anim <= 0.01 { return; } let target_anim = if target_visible { 1.0 } else { 0.0 }; if (anim - target_anim).abs() > 0.001 { ctx.request_repaint(); } let eased = ease_in_out(anim); let screen = ctx.content_rect(); let panel_width = responsive_panel_width(ctx, PREFERRED_PANEL_WIDTH, 260.0); let panel_height = responsive_panel_height(ctx, PREFERRED_PANEL_HEIGHT, 220.0); let viewport = viewport_panel_rect(ctx); let target_pos = egui::pos2( viewport.left(), (viewport.bottom() - panel_height - PANEL_VIEWPORT_MARGIN * 2.0) .max(viewport.top() + layout::TITLE_BAR_HEIGHT + PANEL_VIEWPORT_MARGIN), ); let target_x = target_pos.x; let hidden_x = if target_x < screen.center().x { screen.left() - panel_width - PANEL_OUTSIDE_GAP } else { screen.right() + PANEL_OUTSIDE_GAP }; let x = egui::lerp(hidden_x..=target_x, eased); let y = target_pos.y; egui::Area::new(egui::Id::new("stats_panel")) .fixed_pos(egui::pos2(x, y)) .constrain_to(viewport_panel_rect(ctx)) .order(egui::Order::Foreground) .show(ctx, |ui| { panel_frame(ctx).show(ui, |ui| { ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); ui.set_min_width(panel_width); ui.set_max_width(panel_width); ui.set_min_height(panel_height); draw_force_chart_panel_contents(ui, "RF", "合力", force_history, panel_height); }); }); } const FORCE_CHART_MAX_N: f32 = 25.6; const FORCE_CHART_SAMPLE_SECONDS: f32 = 0.1; const FORCE_PANEL_ACTIVE_TAIL: usize = 8; const HAND_FORCE_PANEL_MIN_WIDTH: f32 = 220.0; const HAND_FORCE_PANEL_MAX_WIDTH: f32 = 500.0; const HAND_FORCE_PANEL_MIN_HEIGHT: f32 = 104.0; const HAND_FORCE_PANEL_MAX_HEIGHT: f32 = 190.0; const HAND_FORCE_PANEL_GAP: f32 = 12.0; const HAND_FORCE_PANEL_SIDE_MARGIN: f32 = 24.0; const HAND_FORCE_PANEL_VERTICAL_MARGIN: f32 = 28.0; const HAND_FORCE_PANEL_TITLES: [(&str, &str); 10] = [ ("T", "顶部 2×4"), ("L3", "左侧 3×1"), ("L5", "左侧 5×1"), ("L8", "左侧 8×1"), ("L10", "左侧 10×1"), ("C", "中央 11×4"), ("R3", "右侧 3×1"), ("R5", "右侧 5×1"), ("R8", "右侧 8×1"), ("R10", "右侧 10×1"), ]; fn has_recent_resultant_force(values: &[f32]) -> bool { values .iter() .rev() .take(FORCE_PANEL_ACTIVE_TAIL) .any(|value| *value > 0.0) } pub fn draw_hand_force_panels(ctx: &egui::Context, visible: bool, histories: &[Vec]) { let hand_active = histories .iter() .any(|history| has_recent_resultant_force(history)); let target_visible = visible && hand_active; let screen = ctx.content_rect(); let side_margin = HAND_FORCE_PANEL_SIDE_MARGIN.min((screen.width() * 0.025).max(10.0)); let vertical_margin = HAND_FORCE_PANEL_VERTICAL_MARGIN.min((screen.height() * 0.04).max(10.0)); let gap = HAND_FORCE_PANEL_GAP.min((screen.height() * 0.018).max(6.0)); let side_width = ((screen.width() - side_margin * 4.0) * 0.25).max(0.0); let panel_width = side_width .min(HAND_FORCE_PANEL_MAX_WIDTH) .max(HAND_FORCE_PANEL_MIN_WIDTH.min(side_width)); let left_x = screen.left() + side_margin; let right_x = screen.right() - side_margin - panel_width; let left_count = 5usize; let right_count = HAND_FORCE_PANEL_TITLES.len() - left_count; let available_height = (screen.height() - layout::TITLE_BAR_HEIGHT - vertical_margin * 2.0).max(0.0); let panel_height = ((available_height - (left_count - 1) as f32 * gap) / left_count as f32) .clamp( HAND_FORCE_PANEL_MIN_HEIGHT.min(available_height), HAND_FORCE_PANEL_MAX_HEIGHT, ); let left_height = left_count as f32 * panel_height + (left_count - 1) as f32 * gap; let right_height = right_count as f32 * panel_height + (right_count.saturating_sub(1)) as f32 * gap; let min_top = screen.top() + layout::TITLE_BAR_HEIGHT + vertical_margin; let left_top = (screen.center().y - left_height * 0.5).max(min_top); let right_top = (screen.center().y - right_height * 0.5).max(min_top); for (index, &(code, title)) in HAND_FORCE_PANEL_TITLES.iter().enumerate() { let history = histories.get(index).map(Vec::as_slice).unwrap_or(&[]); let (target_x, target_y) = if index < left_count { (left_x, left_top + index as f32 * (panel_height + gap)) } else { let right_index = index - left_count; ( right_x, right_top + right_index as f32 * (panel_height + gap), ) }; draw_force_chart_area( ctx, egui::Id::new(format!("hand_force_panel_{index}")), egui::pos2(target_x, target_y), egui::vec2(panel_width, panel_height), target_visible, code, title, history, ); } } #[allow(clippy::too_many_arguments)] fn draw_force_chart_area( ctx: &egui::Context, id: egui::Id, target_pos: egui::Pos2, size: egui::Vec2, target_visible: bool, code: &'static str, title: &'static str, values: &[f32], ) { let anim = ctx.animate_bool(id.with("enter"), target_visible); if anim <= 0.01 { return; } let target_anim = if target_visible { 1.0 } else { 0.0 }; if (anim - target_anim).abs() > 0.001 { ctx.request_repaint(); } let eased = ease_in_out(anim); let hidden_y = ctx.content_rect().bottom() + HAND_FORCE_PANEL_GAP; let y = egui::lerp(hidden_y..=target_pos.y, eased); egui::Area::new(id) .fixed_pos(egui::pos2(target_pos.x, y)) .constrain_to(viewport_panel_rect(ctx)) .order(egui::Order::Foreground) .show(ctx, |ui| { panel_frame(ctx).show(ui, |ui| { ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0); ui.set_min_width(size.x); ui.set_max_width(size.x); ui.set_min_height(size.y); draw_force_chart_panel_contents(ui, code, title, values, size.y); }); }); } fn draw_force_chart_panel_contents( ui: &mut egui::Ui, code: &'static str, title: &'static str, values: &[f32], content_height: f32, ) { let latest = values.last().copied().unwrap_or(0.0); ui.horizontal(|ui| { ui.colored_label(ACCENT_BLUE, code); ui.label(style::panel_title(title)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.colored_label(ACCENT_GREEN, format!("{latest:.1} N")); }); }); ui.add_space(6.0); let chart_height = (content_height - 32.0).max(90.0); paint_resultant_force_chart(ui, values, chart_height); } fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32], chart_height: f32) { const CHART_POINTS: usize = 42; const CHART_WINDOW_SECONDS: f32 = CHART_POINTS as f32 * FORCE_CHART_SAMPLE_SECONDS; let width = ui.available_width().max(180.0); let (rect, _) = ui.allocate_exact_size(egui::vec2(width, chart_height), egui::Sense::hover()); let painter = ui.painter_at(rect); let radius = egui::CornerRadius::same(5); let time = ui.ctx().input(|input| input.time) as f32; painter.rect_filled(rect, radius, egui::Color32::from_rgb(10, 18, 22)); painter.rect_stroke( rect, radius, egui::Stroke::new(1.0_f32, color_alpha(ONE_DARK_PRO.accent, 95)), egui::StrokeKind::Outside, ); let chart_rect = egui::Rect::from_min_max( rect.left_top() + egui::vec2(34.0, 12.0), rect.right_bottom() - egui::vec2(10.0, 24.0), ); paint_force_grid(&painter, rect, chart_rect, time, CHART_WINDOW_SECONDS); if values.is_empty() { painter.text( chart_rect.center(), egui::Align2::CENTER_CENTER, "等待合力数据", egui::FontId::proportional(12.0), ONE_DARK_PRO.text_subtle, ); return; } ui.ctx().request_repaint(); let enter = ui .ctx() .animate_bool(egui::Id::new("resultant_force_chart_enter"), true); let slide_offset = (1.0 - enter) * -chart_rect.width() * 0.65; let tick_phase = (time / FORCE_CHART_SAMPLE_SECONDS).fract(); let scroll_offset = -tick_phase * chart_rect.width() / CHART_POINTS.max(1) as f32; let plot_rect = chart_rect.translate(egui::vec2(slide_offset + scroll_offset, 0.0)); let mut points = Vec::with_capacity(values.len()); let start_slot = CHART_POINTS.saturating_sub(values.len()); for (index, value) in values.iter().enumerate() { let denom = (CHART_POINTS - 1).max(1) as f32; let slot = start_slot + index; let x = plot_rect.left() + (slot as f32 / denom) * plot_rect.width(); let normalized = (*value / FORCE_CHART_MAX_N).clamp(0.0, 1.0); let y = plot_rect.bottom() - normalized * plot_rect.height(); points.push(egui::pos2(x, y)); } let plot_painter = painter.with_clip_rect(chart_rect.expand2(egui::vec2(1.0, 1.0))); paint_force_area(&plot_painter, &points, plot_rect); if points.len() >= 2 { plot_painter.add(egui::Shape::line( points.clone(), egui::Stroke::new(4.0_f32, color_alpha(ONE_DARK_PRO.accent, 45)), )); plot_painter.add(egui::Shape::line( points.clone(), egui::Stroke::new(1.7_f32, ONE_DARK_PRO.accent), )); } if let Some(last) = points.last().copied() { let pulse = (time * 1.8).fract(); plot_painter.circle_stroke( last, 5.0 + pulse * 12.0, egui::Stroke::new( 1.0_f32, color_alpha(ACCENT_GREEN, ((1.0 - pulse) * 120.0) as u8), ), ); plot_painter.circle_filled(last, 3.6, ACCENT_GREEN); plot_painter.circle_filled(last, 1.6, egui::Color32::WHITE); } } fn paint_force_grid( painter: &egui::Painter, rect: egui::Rect, chart_rect: egui::Rect, time: f32, window_seconds: f32, ) { for tick in [25.0, 20.0, 15.0, 10.0, 5.0, 0.0] { let normalized = (tick / FORCE_CHART_MAX_N).clamp(0.0, 1.0); let y = chart_rect.bottom() - normalized * chart_rect.height(); painter.line_segment( [ egui::pos2(chart_rect.left(), y), egui::pos2(chart_rect.right(), y), ], egui::Stroke::new(1.0_f32, color_alpha(ONE_DARK_PRO.border, 78)), ); painter.text( egui::pos2(rect.left() + 8.0, y), egui::Align2::LEFT_CENTER, format!("{tick:.0}"), egui::FontId::monospace(9.0), ONE_DARK_PRO.text_dim, ); } for index in 0..=5 { let x = chart_rect.left() + index as f32 / 5.0 * chart_rect.width(); painter.line_segment( [ egui::pos2(x, chart_rect.top()), egui::pos2(x, chart_rect.bottom()), ], egui::Stroke::new(1.0_f32, color_alpha(ONE_DARK_PRO.border_soft, 42)), ); } let start_time = (time - window_seconds).max(0.0); for index in 0..=5 { let fraction = index as f32 / 5.0; let x = chart_rect.left() + fraction * chart_rect.width(); let label_time = start_time + fraction * (time - start_time); let align = match index { 0 => egui::Align2::LEFT_CENTER, 5 => egui::Align2::RIGHT_CENTER, _ => egui::Align2::CENTER_CENTER, }; painter.text( egui::pos2(x, rect.bottom() - 11.0), align, format!("{label_time:.1}s"), egui::FontId::monospace(9.0), ONE_DARK_PRO.text_subtle, ); } } fn paint_force_area(painter: &egui::Painter, points: &[egui::Pos2], plot_rect: egui::Rect) { if points.len() < 2 { return; } for pair in points.windows(2) { let left = pair[0]; let right = pair[1]; let area = vec![ egui::pos2(left.x, plot_rect.bottom()), left, right, egui::pos2(right.x, plot_rect.bottom()), ]; painter.add(egui::Shape::convex_polygon( area, color_alpha(ONE_DARK_PRO.accent, 32), egui::Stroke::NONE, )); } } fn color_alpha(color: egui::Color32, alpha: u8) -> egui::Color32 { egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), alpha) } fn draw_floating_panel( ctx: &egui::Context, panel: &mut FloatingPanelState, title: &'static str, id: &'static str, preferred_width: f32, min_width: f32, 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 panel_width = responsive_panel_width(ctx, preferred_width, min_width); let max_height = responsive_panel_height(ctx, ctx.content_rect().height(), 180.0); let default_pos = responsive_panel_pos(ctx, panel.default_pos, panel_width); let window_response = egui::Window::new(title) .id(egui::Id::new(id)) .open(&mut open) .default_pos(default_pos) .max_width(panel_width) .max_height(max_height) .constrain_to(viewport_panel_rect(ctx)) .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.set_min_width(panel_width); ui.set_max_width(panel_width); ui.horizontal(|ui| { if ui .add(rich_tag_button("隐藏", style::ONE_DARK_PRO.text_dim)) .clicked() { hide_requested = true; } ui.add_space(6.0); ui.label(style::panel_title(title)); }); ui.separator(); let content_max_height = (max_height - 60.0).max(120.0); egui::ScrollArea::vertical() .max_height(content_max_height) .auto_shrink([false, true]) .show(ui, |ui| { ui.set_min_width(panel_width); ui.set_max_width(panel_width); add_contents(ui); }); }); if let Some(response) = window_response { window_rect = Some(response.response.rect); } if hide_requested && 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 PREFERRED_PANEL_WIDTH: f32 = 520.0; const SLIDE_DISTANCE: f32 = 18.0; let panel_width = responsive_panel_width(ctx, PREFERRED_PANEL_WIDTH, 320.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_f32, ONE_DARK_PRO.border), )); ui.painter().line_segment( [points[2], points[3]], egui::Stroke::new(1.0_f32, 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_f32, 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_f32, 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_f32, ONE_DARK_PRO.border); let accent_stroke = egui::Stroke::new(1.2_f32, 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_f32, 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_wrapped(|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_wrapped(|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 && 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 if narrow_panel(ui) { ui.vertical(|ui| { ui.colored_label(dim_text(), "导出路径"); ui.add_sized( egui::vec2(ui.available_width().max(180.0), METRICS.field_height), egui::TextEdit::singleline(export_path).hint_text("eskin_export_*.csv"), ); }); } else { 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, ) { let panel_width = responsive_panel_width(ctx, 340.0, 280.0); draw_floating_panel( ctx, panel, "录制导出", "export_panel", 340.0, 280.0, |ui| { ui.set_min_width(panel_width); ui.set_max_width(panel_width); 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, ) { let panel_width = responsive_panel_width(ctx, 380.0, 280.0); draw_floating_panel( ctx, panel, "矩阵配置", "matrix_config_panel", 380.0, 280.0, |ui| { ui.set_min_width(panel_width); ui.set_max_width(panel_width); ui.horizontal_wrapped(|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_wrapped(|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(rich_tag_button(*name, style::ONE_DARK_PRO.text_dim)) .clicked() { config.rows = *r; config.cols = *c; } } }); ui.add_space(METRICS.item_gap); ui.horizontal_wrapped(|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(rich_tag_button("重置默认", style::ONE_DARK_PRO.text_dim)) .clicked() { *config = MatrixConfigState::default(); } }, ); } pub fn panel_restore_item(ui: &mut egui::Ui, title: &'static str, panel: &mut FloatingPanelState) { let button_size = egui::vec2(ui.available_width(), 0.0); if panel.visible { ui.add_enabled_ui(false, |ui| { ui.add_sized(button_size, egui::Button::new(format!("{title} 已显示"))); }); } else if ui .add_sized(button_size, egui::Button::new(format!("显示 {title}"))) .clicked() { panel.visible = true; ui.close(); } }