2053 lines
66 KiB
Rust
2053 lines
66 KiB
Rust
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, 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<f64>,
|
||
}
|
||
|
||
pub struct ConfigPanelState {
|
||
pub mode: SerialMode,
|
||
pub port: String,
|
||
pub available_ports: Vec<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<String>,
|
||
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,
|
||
}
|
||
|
||
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(|| "COM3".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(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, 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()
|
||
{
|
||
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_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(),
|
||
);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
pub fn draw_config_panel(
|
||
ctx: &egui::Context,
|
||
panel: &mut FloatingPanelState,
|
||
config: &mut ConfigPanelState,
|
||
connection: &ConnectionManager,
|
||
recorder: &Recorder,
|
||
stats: SerialIoStats,
|
||
sample_rate_hz: f32,
|
||
render_rate_hz: f32,
|
||
) -> Option<SerialMode> {
|
||
let mut changed_mode = None;
|
||
let conn_state = connection.state();
|
||
let panel_width = responsive_panel_width(ctx, 560.0, 340.0);
|
||
config.connected = matches!(
|
||
conn_state,
|
||
ConnectionState::Connected | ConnectionState::Streaming
|
||
);
|
||
panel.visible = true;
|
||
|
||
draw_pinned_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,
|
||
sample_rate_hz,
|
||
render_rate_hz,
|
||
);
|
||
});
|
||
changed_mode
|
||
}
|
||
|
||
pub fn draw_spatial_force_panel(
|
||
ctx: &egui::Context,
|
||
force: Option<HudSpatialForce>,
|
||
force_history: &[f32],
|
||
) {
|
||
let Some(force) = force else {
|
||
return;
|
||
};
|
||
|
||
let target_visible = has_recent_resultant_force(force_history);
|
||
let anim = ctx.animate_bool(egui::Id::new("spatial_force_panel_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 alpha = (anim * 242.0).round() as u8;
|
||
let panel_width = responsive_panel_width(ctx, 380.0, 260.0);
|
||
let panel_height = responsive_panel_height(ctx, 340.0, 220.0);
|
||
let viewport = viewport_panel_rect(ctx);
|
||
let pos = egui::pos2(
|
||
viewport.right() - panel_width,
|
||
viewport.bottom() - panel_height - PANEL_VIEWPORT_MARGIN,
|
||
);
|
||
|
||
egui::Area::new(egui::Id::new("spatial_force_panel"))
|
||
.fixed_pos(pos)
|
||
.constrain_to(viewport)
|
||
.order(egui::Order::Foreground)
|
||
.show(ctx, |ui| {
|
||
faded_panel_frame(ctx, alpha).show(ui, |ui| {
|
||
ui.set_min_width(panel_width);
|
||
ui.set_max_width(panel_width);
|
||
ui.set_min_height(panel_height);
|
||
|
||
let text_dim = color_alpha(ONE_DARK_PRO.text_dim, alpha);
|
||
let accent = color_alpha(ACCENT_ORANGE, alpha);
|
||
let green = color_alpha(ACCENT_GREEN, alpha);
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.colored_label(accent, "3D FORCE");
|
||
ui.label(egui::RichText::new("三维力方向").color(text_dim).size(17.0));
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
ui.colored_label(
|
||
green,
|
||
egui::RichText::new("LIVE").color(green).size(12.0),
|
||
);
|
||
});
|
||
});
|
||
ui.separator();
|
||
|
||
ui.vertical_centered(|ui| {
|
||
let gauge_size = (panel_height - 52.0).min(panel_width - 24.0).max(168.0);
|
||
let (rect, _) = ui.allocate_exact_size(
|
||
egui::vec2(gauge_size, gauge_size),
|
||
egui::Sense::hover(),
|
||
);
|
||
paint_spatial_force_gauge(ui.painter(), rect, force, alpha);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option<SerialMode> {
|
||
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, "游戏体验") {
|
||
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_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().min(150.0).max(104.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,
|
||
sample_rate_hz: f32,
|
||
render_rate_hz: f32,
|
||
) {
|
||
let _ = (config.mode, conn_state, stats);
|
||
draw_realtime_rate_row(ui, sample_rate_hz, render_rate_hz);
|
||
}
|
||
|
||
fn draw_realtime_rate_row(ui: &mut egui::Ui, sample_rate_hz: f32, render_rate_hz: f32) {
|
||
group_frame().show(ui, |ui| {
|
||
ui.horizontal_wrapped(|ui| {
|
||
ui.colored_label(dim_text(), "实时采样率");
|
||
ui.label(style::value_text(format!("{sample_rate_hz:.1} Hz")));
|
||
ui.add_space(18.0);
|
||
ui.colored_label(dim_text(), "画面刷新率");
|
||
ui.label(style::value_text(format!("{render_rate_hz:.1} Hz")));
|
||
});
|
||
});
|
||
}
|
||
|
||
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::WidgetText>,
|
||
) -> 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<egui::WidgetText>,
|
||
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, 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<HudSpatialForce>,
|
||
) {
|
||
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); 7] = [
|
||
("T1", "拇指"),
|
||
("T2", "食指"),
|
||
("T3", "中指"),
|
||
("T4", "无名指"),
|
||
("T5", "小指"),
|
||
("P1", "掌心横区"),
|
||
("P2", "掌心纵区"),
|
||
];
|
||
|
||
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<f32>]) {
|
||
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 = 4usize;
|
||
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 in 0..HAND_FORCE_PANEL_TITLES.len() {
|
||
let history = histories.get(index).map(Vec::as_slice).unwrap_or(&[]);
|
||
let (code, title) = HAND_FORCE_PANEL_TITLES[index];
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
|
||
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, 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 plot_rect = chart_rect.translate(egui::vec2(slide_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, color_alpha(ONE_DARK_PRO.accent, 45)),
|
||
));
|
||
plot_painter.add(egui::Shape::line(
|
||
points.clone(),
|
||
egui::Stroke::new(1.7, 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,
|
||
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, 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, 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 faded_panel_frame(ctx: &egui::Context, alpha: u8) -> egui::Frame {
|
||
let style = ctx.global_style();
|
||
egui::Frame::window(&style)
|
||
.fill(color_alpha(ONE_DARK_PRO.panel, alpha))
|
||
.stroke(egui::Stroke::new(
|
||
1.0,
|
||
color_alpha(ONE_DARK_PRO.border, alpha),
|
||
))
|
||
.corner_radius(egui::CornerRadius::same(ONE_DARK_PRO.radius))
|
||
.inner_margin(egui::Margin::same(METRICS.panel_padding))
|
||
.shadow(egui::epaint::Shadow {
|
||
offset: [0, 14],
|
||
blur: 28,
|
||
spread: 0,
|
||
color: egui::Color32::from_black_alpha((alpha as f32 * 0.45) as u8),
|
||
})
|
||
}
|
||
|
||
fn paint_spatial_force_gauge(
|
||
painter: &egui::Painter,
|
||
rect: egui::Rect,
|
||
force: HudSpatialForce,
|
||
alpha: u8,
|
||
) {
|
||
let center = rect.center();
|
||
let radius = rect.width().min(rect.height()) * 0.42;
|
||
let angle = force.angle_deg.to_radians();
|
||
let direction = egui::vec2(angle.cos(), angle.sin());
|
||
let magnitude = force.magnitude.clamp(0.0, 1.0);
|
||
let end = center + direction * (radius * (0.35 + magnitude * 0.65));
|
||
let side = egui::vec2(-direction.y, direction.x);
|
||
|
||
painter.circle_stroke(
|
||
center,
|
||
radius,
|
||
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border, alpha)),
|
||
);
|
||
painter.circle_stroke(
|
||
center,
|
||
radius * 0.55,
|
||
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border_soft, alpha / 2)),
|
||
);
|
||
painter.line_segment(
|
||
[
|
||
center + egui::vec2(-radius, 0.0),
|
||
center + egui::vec2(radius, 0.0),
|
||
],
|
||
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border_soft, alpha / 3)),
|
||
);
|
||
painter.line_segment(
|
||
[
|
||
center + egui::vec2(0.0, -radius),
|
||
center + egui::vec2(0.0, radius),
|
||
],
|
||
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border_soft, alpha / 3)),
|
||
);
|
||
painter.circle_filled(
|
||
end,
|
||
12.0,
|
||
color_alpha(ACCENT_ORANGE, (alpha as f32 * 0.16) as u8),
|
||
);
|
||
painter.line_segment(
|
||
[center, end],
|
||
egui::Stroke::new(4.0, color_alpha(ACCENT_ORANGE, alpha)),
|
||
);
|
||
painter.line_segment(
|
||
[end, end - direction * 13.0 + side * 7.0],
|
||
egui::Stroke::new(3.0, color_alpha(ACCENT_ORANGE, alpha)),
|
||
);
|
||
painter.line_segment(
|
||
[end, end - direction * 13.0 - side * 7.0],
|
||
egui::Stroke::new(3.0, color_alpha(ACCENT_ORANGE, alpha)),
|
||
);
|
||
painter.circle_filled(center, 4.5, color_alpha(ONE_DARK_PRO.text_dim, alpha));
|
||
painter.circle_filled(end, 5.5, color_alpha(ACCENT_GREEN, alpha));
|
||
painter.circle_filled(end, 2.2, color_alpha(egui::Color32::WHITE, alpha));
|
||
}
|
||
|
||
fn draw_pinned_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),
|
||
) {
|
||
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 pos = responsive_panel_pos(ctx, panel.default_pos, panel_width);
|
||
|
||
egui::Window::new(title)
|
||
.id(egui::Id::new(id))
|
||
.current_pos(pos)
|
||
.max_width(panel_width)
|
||
.max_height(max_height)
|
||
.constrain_to(viewport_panel_rect(ctx))
|
||
.title_bar(false)
|
||
.resizable(false)
|
||
.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| {
|
||
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);
|
||
});
|
||
});
|
||
}
|
||
|
||
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 {
|
||
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 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<R>(
|
||
ui: &mut egui::Ui,
|
||
width: f32,
|
||
add_contents: impl FnOnce(&mut egui::Ui) -> R,
|
||
) -> egui::InnerResponse<R> {
|
||
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<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_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 {
|
||
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
|
||
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();
|
||
}
|
||
}
|