Files
eskin-player/src/ui.rs
2026-06-26 02:03:07 +08:00

1268 lines
39 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},
recording::Recorder,
style::{
self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO,
accent_text, dim_text, group_frame, layout, panel_frame, tag_button,
},
utils::serial_enum,
};
pub struct FloatingPanelState {
pub visible: bool,
default_pos: egui::Pos2,
tag_pos: egui::Pos2,
center_anim: f32,
center_anim_target: bool,
center_anim_last_time: Option<f64>,
}
pub struct ConfigPanelState {
pub mode: SerialMode,
pub port: String,
pub baud_rate: u32,
pub data_bits: u8,
pub stop_bits: u8,
pub parity: Parity,
pub timeout_ms: u32,
pub module_addr: u8,
pub connected: bool,
pub auto_reconnect: bool,
pub manual_tx: String,
pub model_path: String,
}
pub struct ConnectPanelState {
pub mode: SerialMode,
pub port: Vec<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,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Parity {
None,
Odd,
Even,
}
pub enum IconButtonIcon<'a> {
Font(&'a str),
Png(egui::ImageSource<'a>),
}
impl FloatingPanelState {
pub fn new(default_pos: [f32; 2], tag_pos: [f32; 2]) -> Self {
Self {
visible: true,
default_pos: egui::pos2(default_pos[0], default_pos[1]),
tag_pos: egui::pos2(tag_pos[0], tag_pos[1]),
center_anim: 1.0,
center_anim_target: true,
center_anim_last_time: None,
}
}
}
impl Default for ConfigPanelState {
fn default() -> Self {
Self {
mode: SerialMode::Finger,
port: "COM3".to_owned(),
baud_rate: 115_200,
data_bits: 8,
stop_bits: 1,
parity: Parity::None,
timeout_ms: 1000,
module_addr: 1,
connected: false,
auto_reconnect: true,
manual_tx: "01 03 00 00 00 02".to_owned(),
model_path: "model/default.eskin".to_owned(),
}
}
}
impl Default for ConnectPanelState {
fn default() -> Self {
let port = serial_enum().unwrap_or_default();
let selected_port = port.first().cloned().unwrap_or_default();
Self {
mode: SerialMode::Finger,
port,
selected_port,
duration: 10,
manual: false,
rows: 12,
cols: 7,
connection: false,
}
}
}
pub fn draw_scene_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) {
draw_floating_panel(ctx, panel, "场景", "scene_panel", |ui| {
ui.horizontal(|ui| {
ui.colored_label(dim_text(), "视图");
let _ = ui.selectable_label(true, "");
let _ = ui.selectable_label(false, "三角形");
});
ui.separator();
group_frame().show(ui, |ui| {
ui.label("模型 / 材质 / 灯光");
ui.label("目标任务 64");
ui.label("缓存命中 100.0%");
});
});
}
pub fn draw_connect_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
config: &mut ConnectPanelState,
connection: &ConnectionManager,
recorder: &Recorder,
export_path: &mut String,
) {
let conn_state = connection.state();
let is_connected = matches!(
conn_state,
ConnectionState::Connected | ConnectionState::Streaming
);
draw_center_floating_panel(
ctx,
panel,
"connect_center_panel",
layout::CENTER_PANEL_TOP,
"连接",
|ui| {
ui.vertical(|ui| {
// centered_mode_row(ui, &mut config.mode);
ui.add_space(METRICS.row_gap);
group_frame().show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(
egui::Image::new(egui::include_image!("../static/cpu.png"))
.fit_to_exact_size(egui::vec2(64.0, 64.0)),
);
ui.add_space(METRICS.item_gap);
ui.vertical(|ui| {
draw_connect_port_row(ui, config);
ui.add_space(6.0);
draw_connect_matrix_row(ui, config);
});
});
});
ui.add_space(METRICS.row_gap);
draw_connect_action_row(ui, conn_state, is_connected, config, connection);
if is_connected {
draw_recording_toolbar(ui, recorder, export_path);
}
});
},
);
}
// fn centered_mode_row(ui: &mut egui::Ui, mode: &mut SerialMode) {
// let button_width = 96.0;
// let gap = METRICS.item_gap;
// let total_width = button_width * 3.0 + gap * 2.0;
// let left_space = ((ui.available_width() - total_width) * 0.5).max(0.0);
// ui.horizontal(|ui| {
// ui.add_space(left_space);
// mode_button(ui, mode, SerialMode::Finger, "指尖模块");
// ui.add_space(gap);
// mode_button(ui, mode, SerialMode::Hand, "手掌模块");
// // ui.add_space(gap);
// // mode_button(ui, mode, SerialMode::Model, "模型");
// });
// }
fn draw_connect_port_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) {
ui.horizontal(|ui| {
ui.label(style::field_label("串口"));
egui::ComboBox::from_id_salt("connect_ports")
.width(150.0)
.selected_text(if config.selected_port.is_empty() {
"无可用串口".to_owned()
} else {
config.selected_port.clone()
})
.show_ui(ui, |ui| {
for port in &config.port {
ui.selectable_value(&mut config.selected_port, port.clone(), port.as_str());
}
});
if ui
.add(style::icon_button(
egui::RichText::new("").color(ONE_DARK_PRO.text).size(16.0),
METRICS.icon_button,
))
.on_hover_text("刷新串口")
.clicked()
{
if let Ok(ports) = serial_enum() {
if !ports.contains(&config.selected_port) {
config.selected_port = ports.first().cloned().unwrap_or_default();
}
config.port = ports;
}
}
ui.add_space(METRICS.item_gap);
ui.label(style::field_label("频率"));
ui.add_sized(
egui::vec2(72.0, METRICS.field_height),
egui::DragValue::new(&mut config.duration).range(1..=120),
);
});
}
fn draw_connect_matrix_row(ui: &mut egui::Ui, config: &mut ConnectPanelState) {
ui.horizontal(|ui| {
ui.checkbox(&mut config.manual, "手动矩阵");
ui.add_enabled_ui(config.manual, |ui| {
ui.add_space(METRICS.item_gap);
ui.label(style::field_label(""));
ui.add_sized(
egui::vec2(56.0, METRICS.field_height),
egui::DragValue::new(&mut config.rows).range(1..=64),
);
ui.label(style::field_label(""));
ui.add_sized(
egui::vec2(56.0, METRICS.field_height),
egui::DragValue::new(&mut config.cols).range(1..=64),
);
});
});
}
fn draw_connect_action_row(
ui: &mut egui::Ui,
conn_state: ConnectionState,
is_connected: bool,
config: &ConnectPanelState,
connection: &ConnectionManager,
) {
ui.horizontal(|ui| {
let status_text = match conn_state {
ConnectionState::Disconnected => "未连接",
ConnectionState::Connecting => "连接中...",
ConnectionState::Connected => "已连接",
ConnectionState::Streaming => "采集中",
ConnectionState::Error => "连接错误",
};
let status_color = match conn_state {
ConnectionState::Disconnected => ONE_DARK_PRO.text_dim,
ConnectionState::Connecting => style::status_color_warn(),
ConnectionState::Connected => style::status_color_ok(),
ConnectionState::Streaming => style::status_color_info(),
ConnectionState::Error => style::status_color_error(),
};
ui.colored_label(status_color, status_text);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let button = if is_connected {
style::danger_button("断开")
} else {
style::primary_button("连接")
};
if ui.add(button).clicked() {
if is_connected {
connection.disconnect();
} else if !config.selected_port.is_empty() {
connection.connect(
&config.selected_port,
config.rows as u32,
config.cols as u32,
);
}
}
});
});
}
pub fn draw_config_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
config: &mut ConfigPanelState,
) -> Option<SerialMode> {
let mut changed_mode = None;
draw_floating_panel(ctx, panel, "配置", "config_panel", |ui| {
ui.set_min_width(560.0);
if let Some(mode) = draw_mode_row(ui, config) {
changed_mode = Some(mode);
}
// draw_mode_row(ui, config);
ui.separator();
draw_connection_row(ui, config);
ui.add_space(8.0);
draw_serial_grid(ui, config);
ui.add_space(8.0);
draw_mode_body(ui, config);
});
changed_mode
}
fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option<SerialMode> {
let mut changed_to = None;
ui.horizontal(|ui| {
ui.colored_label(dim_text(), "模式");
ui.add_space(12.0);
if mode_button(ui, &mut config.mode, SerialMode::Finger, "指尖模块") {
changed_to = Some(SerialMode::Finger)
}
if mode_button(ui, &mut config.mode, SerialMode::Hand, "手掌模块") {
changed_to = Some(SerialMode::Hand)
}
// mode_button(ui, &mut config.mode, SerialMode::Model, "模型");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.checkbox(&mut config.auto_reconnect, "自动");
ui.colored_label(dim_text(), "重连");
});
});
changed_to
}
fn draw_connection_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
ui.horizontal(|ui| {
ui.add(
egui::Image::new(egui::include_image!("../static/cpu.png"))
.fit_to_exact_size(egui::vec2(72.0, 72.0)),
);
ui.add_space(METRICS.item_gap);
ui.vertical(|ui| {
ui.label(style::value_text(format!("端口 {}", config.port)));
ui.label(style::value_text(format!("波特率 {}", config.baud_rate)));
let status = if config.connected {
"已连接"
} else {
"未连接"
};
let status_color = if config.connected {
style::status_color_ok()
} else {
style::status_color_error()
};
ui.colored_label(status_color, status);
});
ui.add_space(22.0);
let button_text = if config.connected { "断开" } else { "连接" };
if ui
.add(if config.connected {
style::danger_button(button_text)
} else {
style::primary_button(button_text)
})
.clicked()
{
config.connected = !config.connected;
}
ui.add_space(18.0);
ui.colored_label(
if config.auto_reconnect {
style::status_color_ok()
} else {
ONE_DARK_PRO.text_dim
},
if config.auto_reconnect {
"链路保护 开"
} else {
"链路保护 关"
},
);
});
}
fn draw_serial_grid(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
group_frame().show(ui, |ui| {
egui::Grid::new("serial_config_grid")
.num_columns(4)
.spacing(egui::vec2(14.0, 7.0))
.striped(true)
.show(ui, |ui| {
ui.label(style::field_label("端口"));
ui.add_sized(
egui::vec2(110.0, METRICS.field_height),
egui::TextEdit::singleline(&mut config.port),
);
ui.label(style::field_label("波特率"));
baud_combo(ui, config);
ui.end_row();
ui.label(style::field_label("数据位"));
ui.add_sized(
egui::vec2(70.0, METRICS.field_height),
egui::DragValue::new(&mut config.data_bits).range(5..=8),
);
ui.label(style::field_label("校验"));
parity_combo(ui, config);
ui.end_row();
ui.label(style::field_label("停止位"));
ui.add_sized(
egui::vec2(70.0, METRICS.field_height),
egui::DragValue::new(&mut config.stop_bits).range(1..=2),
);
ui.label(style::field_label("超时"));
ui.horizontal(|ui| {
ui.add_sized(
egui::vec2(84.0, METRICS.field_height),
egui::DragValue::new(&mut config.timeout_ms)
.range(50..=30_000)
.speed(50),
);
ui.colored_label(dim_text(), "毫秒");
});
ui.end_row();
});
});
}
fn draw_mode_body(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
group_frame().show(ui, |ui| match config.mode {
SerialMode::Finger => {
ui.horizontal(|ui| {
ui.label(style::field_label("模块地址"));
ui.add_sized(
egui::vec2(80.0, METRICS.field_height),
egui::DragValue::new(&mut config.module_addr).range(1..=247),
);
ui.add_space(16.0);
if ui.add(tag_button("读取信息")).clicked() {}
if ui.add(tag_button("探测")).clicked() {}
});
ui.separator();
ui.horizontal(|ui| {
ui.colored_label(dim_text(), "状态");
ui.label("就绪");
ui.colored_label(dim_text(), "接收");
ui.label("0 字节");
ui.colored_label(dim_text(), "发送");
ui.label("0 字节");
});
}
SerialMode::Hand => {
ui.horizontal(|ui| {
ui.label(style::field_label("发送"));
ui.add_sized(
egui::vec2(300.0, METRICS.field_height),
egui::TextEdit::singleline(&mut config.manual_tx),
);
if ui.add(tag_button("发送")).clicked() {}
if ui.add(tag_button("清空")).clicked() {
config.manual_tx.clear();
}
});
} // SerialMode::Model => {
// ui.horizontal(|ui| {
// ui.label(style::field_label("模型"));
// ui.add_sized(
// egui::vec2(300.0, METRICS.field_height),
// egui::TextEdit::singleline(&mut config.model_path),
// );
// if ui.add(tag_button("加载")).clicked() {}
// if ui.add(tag_button("运行")).clicked() {}
// });
// }
});
}
pub fn icon_button<'a>(
ui: &mut egui::Ui,
icon: IconButtonIcon<'a>,
tooltip: impl Into<egui::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,
] {
ui.selectable_value(&mut config.baud_rate, baud_rate, baud_rate.to_string());
}
});
}
fn parity_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
egui::ComboBox::from_id_salt("serial_parity")
.width(110.0)
.selected_text(match config.parity {
Parity::None => "",
Parity::Odd => "",
Parity::Even => "",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut config.parity, Parity::None, "");
ui.selectable_value(&mut config.parity, Parity::Odd, "");
ui.selectable_value(&mut config.parity, Parity::Even, "");
});
}
pub fn draw_stats_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) {
draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| {
ui.horizontal(|ui| {
ui.colored_label(accent_text(), "0.030");
ui.label(style::subtle_text("81m:51s"));
});
ui.separator();
group_frame().show(ui, |ui| {
ui.label(style::field_label("帧率 / GPU 信息"));
ui.label(style::value_text("边界 589.0us"));
ui.label(style::value_text("簇 12.8ms"));
});
});
}
fn draw_floating_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
title: &'static str,
id: &'static str,
add_contents: impl FnOnce(&mut egui::Ui),
) {
if panel.visible {
let mut open = true;
let mut hide_requested = false;
let mut window_rect = None;
let window_response = egui::Window::new(title)
.id(egui::Id::new(id))
.open(&mut open)
.default_pos(panel.default_pos)
.title_bar(false)
.resizable(true)
.frame(panel_frame(ctx))
.show(ctx, |ui| {
ui.spacing_mut().item_spacing = egui::vec2(METRICS.item_gap, 6.0);
ui.horizontal(|ui| {
if ui.add(tag_button("隐藏")).clicked() {
hide_requested = true;
}
ui.add_space(6.0);
ui.label(style::panel_title(title));
});
ui.separator();
add_contents(ui);
});
if let Some(response) = window_response {
window_rect = Some(response.response.rect);
}
if hide_requested {
if let Some(rect) = window_rect {
let screen = ctx.content_rect();
let tag_size = egui::vec2(86.0, 22.0);
let distance_to_left = rect.left();
let distance_to_right = screen.right() - rect.right();
let x = if distance_to_left <= distance_to_right {
screen.left()
} else {
screen.right() - tag_size.x
};
let y = rect.top().clamp(screen.top(), screen.bottom() - tag_size.y);
panel.tag_pos = egui::pos2(x, y);
}
}
panel.visible = open && !hide_requested;
}
// else {
// let response = egui::Area::new(egui::Id::new(format!("{id}_tag")))
// .current_pos(panel.tag_pos)
// .movable(true)
// .order(egui::Order::Foreground)
// .show(ctx, |ui| {
// ui.set_min_width(86.0);
// if ui
// .add(tag_button(format!("▸ {title}")).min_size(egui::vec2(86.0, 22.0)))
// .clicked()
// {
// panel.visible = true;
// }
// });
// panel.tag_pos = response.response.rect.min;
// }
}
fn draw_center_floating_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
id: &'static str,
top_offset: f32,
collapsed_label: &'static str,
add_contents: impl FnOnce(&mut egui::Ui),
) {
const PANEL_WIDTH: f32 = 520.0;
const SLIDE_DISTANCE: f32 = 18.0;
let anim = advance_center_panel_anim(ctx, panel);
let eased = ease_in_out(anim);
if !panel.visible || anim < 0.28 {
let handle_y = top_offset - (1.0 - eased) * 2.0;
egui::Area::new(egui::Id::new(format!("{id}_handle")))
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, handle_y))
.order(egui::Order::Tooltip)
.show(ctx, |ui| {
let handle_rect = ui
.allocate_exact_size(egui::vec2(112.0, 24.0), egui::Sense::hover())
.0;
if integrated_handle(ui, handle_rect, collapsed_label, false, "打开连接面板")
.clicked()
{
panel.visible = true;
}
});
}
if anim <= 0.02 {
return;
}
egui::Area::new(egui::Id::new(id))
.anchor(
egui::Align2::CENTER_TOP,
egui::vec2(0.0, top_offset - (1.0 - eased) * SLIDE_DISTANCE),
)
.order(egui::Order::Tooltip)
.show(ctx, |ui| {
let response = center_panel_shell(ui, PANEL_WIDTH, |ui| {
ui.set_width(PANEL_WIDTH);
add_contents(ui);
ui.add_space(6.0);
let handle_rect = allocate_center_panel_handle(ui);
if integrated_handle(ui, handle_rect, "", true, "收起连接面板").clicked() {
panel.visible = false;
}
handle_rect
});
paint_integrated_center_panel(ui, response.response.rect, response.inner, true);
});
}
fn advance_center_panel_anim(ctx: &egui::Context, panel: &mut FloatingPanelState) -> f32 {
let target = if panel.visible { 1.0 } else { 0.0 };
let now = ctx.input(|input| input.time);
let delta_time = panel
.center_anim_last_time
.map(|last_time| (now - last_time) as f32)
.unwrap_or(1.0 / 60.0)
.clamp(0.0, 0.05);
panel.center_anim_last_time = Some(now);
panel.center_anim_target = panel.visible;
let speed = 7.5;
let step = speed * delta_time;
if panel.center_anim < target {
panel.center_anim = (panel.center_anim + step).min(target);
} else if panel.center_anim > target {
panel.center_anim = (panel.center_anim - step).max(target);
}
if (panel.center_anim - target).abs() > 0.001 {
ctx.request_repaint();
}
panel.center_anim
}
fn ease_in_out(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
fn center_panel_shell<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(|ui| {
ui.colored_label(ACCENT_RED, "● REC");
ui.colored_label(
ONE_DARK_PRO.text,
format!("{} 帧 | {:.1}s", frame_count, duration_ms as f64 / 1000.0),
);
});
ui.horizontal(|ui| {
// Full recording
let rec_btn = if is_recording {
tag_button("⏹ 停止")
} else {
style::accent_button(
egui::RichText::new("● 全量录制").color(egui::Color32::WHITE),
ACCENT_RED,
)
};
if ui.add(rec_btn).clicked() {
if is_recording {
let _ = recorder.stop_recording();
} else {
let _ = recorder.start_full_recording();
}
}
ui.add_space(4.0);
// Snapshot recording
let snap_btn = if is_recording {
tag_button("⏹ 快照停止")
} else {
style::accent_button(
egui::RichText::new("✂ 快照录制").color(egui::Color32::WHITE),
ACCENT_ORANGE,
)
};
if ui.add(snap_btn).clicked() {
if is_recording {
let _ = recorder.stop_recording();
} else {
let _ = recorder.start_snapshot_recording();
}
}
ui.add_space(4.0);
// Pause/Resume
if is_recording {
if ui.add(tag_button("⏸ 暂停")).clicked() {
let _ = recorder.pause_recording();
}
}
ui.add_space(8.0);
// Export
if ui
.add(style::accent_button(
egui::RichText::new("📤 导出CSV").color(egui::Color32::WHITE),
ACCENT_BLUE,
))
.clicked()
{
let path = if export_path.is_empty() {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("eskin_export_{}.csv", ts)
} else {
export_path.clone()
};
if let Err(e) = recorder.export_csv(&path) {
eprintln!("[export] error: {e}");
}
}
// Import
if ui
.add(style::accent_button(
egui::RichText::new("📥 导入CSV").color(egui::Color32::WHITE),
ACCENT_GREEN,
))
.clicked()
{
let import_path = export_path.clone();
if let Err(e) = recorder.import_csv(&import_path) {
eprintln!("[import] error: {e}");
}
}
});
// Export path
ui.horizontal(|ui| {
ui.colored_label(dim_text(), "导出路径");
ui.add_sized(
egui::vec2(
(ui.available_width() - 80.0).max(160.0),
METRICS.field_height,
),
egui::TextEdit::singleline(export_path).hint_text("eskin_export_*.csv"),
);
});
}
// ── Export Panel (floating) ────────────────────────────────────────────
/// Draw the export/recording floating panel.
pub fn draw_export_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
recorder: &Recorder,
export_path: &mut String,
) {
draw_floating_panel(ctx, panel, "录制导出", "export_panel", |ui| {
ui.set_min_width(300.0);
draw_recording_toolbar(ui, recorder, export_path);
});
}
// ── Matrix Config Panel ────────────────────────────────────────────────
pub struct MatrixConfigState {
pub rows: u32,
pub cols: u32,
pub color_min: f32,
pub color_max: f32,
}
impl Default for MatrixConfigState {
fn default() -> Self {
Self {
rows: 12,
cols: 7,
color_min: 0.0,
color_max: 7000.0,
}
}
}
/// Draw matrix configuration floating panel.
pub fn draw_matrix_config_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
config: &mut MatrixConfigState,
) {
draw_floating_panel(ctx, panel, "矩阵配置", "matrix_config_panel", |ui| {
ui.set_min_width(280.0);
ui.horizontal(|ui| {
ui.label(style::field_label("矩阵尺寸"));
ui.add_space(METRICS.item_gap);
ui.label(style::value_text(""));
ui.add_sized(
egui::vec2(56.0, METRICS.field_height),
egui::DragValue::new(&mut config.rows).range(1..=128),
);
ui.label(style::value_text(""));
ui.add_sized(
egui::vec2(56.0, METRICS.field_height),
egui::DragValue::new(&mut config.cols).range(1..=128),
);
});
ui.add_space(METRICS.item_gap);
ui.horizontal(|ui| {
ui.label(style::field_label("预设"));
for (r, c, name) in &[
(12, 7, "12×7"),
(24, 12, "24×12"),
(32, 16, "32×16"),
(48, 24, "48×24"),
(64, 32, "64×32"),
] {
if ui.add(tag_button(*name)).clicked() {
config.rows = *r;
config.cols = *c;
}
}
});
ui.add_space(METRICS.item_gap);
ui.horizontal(|ui| {
ui.label(style::field_label("色域范围"));
ui.add_space(METRICS.item_gap);
ui.label(style::value_text("最小"));
ui.add_sized(
egui::vec2(72.0, METRICS.field_height),
egui::DragValue::new(&mut config.color_min)
.range(0.0..=10000.0)
.speed(100.0),
);
ui.label(style::value_text("最大"));
ui.add_sized(
egui::vec2(72.0, METRICS.field_height),
egui::DragValue::new(&mut config.color_max)
.range(1.0..=10000.0)
.speed(100.0),
);
});
ui.add_space(METRICS.row_gap);
if ui.add(tag_button("重置默认")).clicked() {
*config = MatrixConfigState::default();
}
});
}
pub fn panel_restore_item(ui: &mut egui::Ui, title: &'static str, panel: &mut FloatingPanelState) {
if panel.visible {
ui.add_enabled(false, egui::Button::new(format!("{title} 已显示")));
} else if ui.button(format!("显示 {title}")).clicked() {
panel.visible = true;
ui.close();
}
}