Files
eskin-player/src/ui.rs

2053 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}