Add pressure visual modes and breakout game

This commit is contained in:
lennlouisgeek
2026-06-29 03:13:57 +08:00
parent eab24fc2bf
commit f30ebcf20b
12 changed files with 1739 additions and 165 deletions

471
src/ui.rs
View File

@@ -3,9 +3,10 @@ use eframe::egui;
use crate::{
connection::{ConnectionManager, ConnectionState},
recording::Recorder,
serial_core::serial::SerialIoStats,
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,
dim_text, group_frame, layout, panel_frame, tag_button,
},
utils::serial_enum,
};
@@ -22,6 +23,7 @@ pub struct FloatingPanelState {
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,
@@ -78,17 +80,24 @@ impl FloatingPanelState {
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: "COM3".to_owned(),
baud_rate: 115_200,
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: true,
auto_reconnect: false,
manual_tx: "01 03 00 00 00 02".to_owned(),
model_path: "model/default.eskin".to_owned(),
}
@@ -307,8 +316,16 @@ pub fn draw_config_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
config: &mut ConfigPanelState,
connection: &ConnectionManager,
) -> Option<SerialMode> {
let mut changed_mode = None;
let conn_state = connection.state();
let stats = connection.stats();
config.connected = matches!(
conn_state,
ConnectionState::Connected | ConnectionState::Streaming
);
draw_floating_panel(ctx, panel, "配置", "config_panel", |ui| {
ui.set_min_width(560.0);
@@ -318,11 +335,12 @@ pub fn draw_config_panel(
// draw_mode_row(ui, config);
ui.separator();
draw_connection_row(ui, config);
draw_connection_row(ui, config, connection, conn_state);
ui.add_space(8.0);
draw_serial_grid(ui, config);
ui.add_space(8.0);
draw_mode_body(ui, config);
// Legacy serial parameter grid is intentionally kept commented for future hardware:
// draw_serial_grid(ui, config);
// ui.add_space(8.0);
draw_mode_body(ui, config, conn_state, stats);
});
changed_mode
}
@@ -341,16 +359,22 @@ fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option<Ser
}
// 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(), "重连");
});
// 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) {
fn draw_connection_row(
ui: &mut egui::Ui,
config: &mut ConfigPanelState,
connection: &ConnectionManager,
conn_state: ConnectionState,
) {
ui.horizontal(|ui| {
ui.add(
egui::Image::new(egui::include_image!("../static/cpu.png"))
@@ -360,48 +384,75 @@ fn draw_connection_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
ui.add_space(METRICS.item_gap);
ui.vertical(|ui| {
ui.label(style::value_text(format!("端口 {}", config.port)));
ui.horizontal(|ui| {
ui.label(style::field_label("端口"));
egui::ComboBox::from_id_salt("config_ports")
.width(126.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)));
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.colored_label(
connection_status_color(conn_state),
connection_status_text(conn_state),
);
});
ui.add_space(22.0);
let button_text = if config.connected { "断开" } else { "连接" };
let is_connected = matches!(
conn_state,
ConnectionState::Connected | ConnectionState::Streaming
);
let button_text = if is_connected { "断开" } else { "连接" };
if ui
.add(if config.connected {
.add(if is_connected {
style::danger_button(button_text)
} else {
style::primary_button(button_text)
})
.clicked()
{
config.connected = !config.connected;
if is_connected {
connection.disconnect();
} else if !config.port.is_empty() {
connection.connect(&config.port, 12, 7);
}
}
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 {
"链路保护 关"
},
);
// 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 {
// "链路保护 关"
// },
// );
});
}
@@ -453,41 +504,41 @@ fn draw_serial_grid(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
});
}
fn draw_mode_body(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
fn draw_mode_body(
ui: &mut egui::Ui,
config: &mut ConfigPanelState,
conn_state: ConnectionState,
stats: SerialIoStats,
) {
group_frame().show(ui, |ui| match config.mode {
SerialMode::Finger => {
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 字节");
});
// Legacy module-address controls for older devices:
// ui.horizontal(|ui| {
// ui.label(style::field_label("模块地址"));
// ui.add_sized(
// egui::vec2(80.0, METRICS.field_height),
// egui::DragValue::new(&mut config.module_addr).range(1..=247),
// );
// ui.add_space(16.0);
// if ui.add(tag_button("读取信息")).clicked() {}
// if ui.add(tag_button("探测")).clicked() {}
// });
draw_status_bytes_row(ui, conn_state, stats);
}
SerialMode::Hand => {
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();
}
});
// Legacy manual-TX controls for older hand-module debugging:
// ui.horizontal(|ui| {
// ui.label(style::field_label("发送"));
// ui.add_sized(
// egui::vec2(300.0, METRICS.field_height),
// egui::TextEdit::singleline(&mut config.manual_tx),
// );
// if ui.add(tag_button("发送")).clicked() {}
// if ui.add(tag_button("清空")).clicked() {
// config.manual_tx.clear();
// }
// });
draw_status_bytes_row(ui, conn_state, stats);
} // SerialMode::Model => {
// ui.horizontal(|ui| {
// ui.label(style::field_label("模型"));
@@ -502,6 +553,45 @@ fn draw_mode_body(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
});
}
fn draw_status_bytes_row(ui: &mut egui::Ui, conn_state: ConnectionState, stats: SerialIoStats) {
ui.horizontal(|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>,
@@ -578,21 +668,236 @@ fn parity_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
});
}
pub fn draw_stats_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) {
pub fn draw_stats_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,
force_history: &[f32],
) {
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"));
});
ui.set_min_width(320.0);
draw_resultant_force_chart(ui, force_history);
});
}
const FORCE_CHART_MAX_N: f32 = 25.6;
fn draw_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) {
let latest = values.last().copied().unwrap_or(0.0);
let max = values.iter().copied().fold(0.0_f32, f32::max);
let active_values = values.iter().copied().filter(|value| *value > 0.0);
let min = active_values.fold(None, |best: Option<f32>, value| {
Some(best.map_or(value, |current| current.min(value)))
});
group_frame().show(ui, |ui| {
ui.horizontal(|ui| {
ui.colored_label(ACCENT_BLUE, "RF");
ui.label(style::panel_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(4.0);
ui.horizontal(|ui| {
force_metric(ui, "峰值", max);
ui.separator();
force_metric(ui, "低值", min.unwrap_or(0.0));
ui.separator();
ui.colored_label(dim_text(), format!("{}", values.len()));
});
ui.add_space(6.0);
paint_resultant_force_chart(ui, values);
});
}
fn force_metric(ui: &mut egui::Ui, label: &'static str, value: f32) {
ui.colored_label(dim_text(), label);
ui.label(style::value_text(format!("{value:.1} N")));
}
fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) {
const CHART_HEIGHT: f32 = 138.0;
const CHART_POINTS: usize = 42;
let width = ui.available_width().max(280.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);
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);
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 time = ui.ctx().input(|input| input.time) as f32;
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_sweep(&plot_painter, chart_rect, time);
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) {
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)),
);
}
painter.text(
egui::pos2(chart_rect.left(), rect.bottom() - 11.0),
egui::Align2::LEFT_CENTER,
"-4.2s",
egui::FontId::monospace(9.0),
ONE_DARK_PRO.text_subtle,
);
painter.text(
egui::pos2(chart_rect.right(), rect.bottom() - 11.0),
egui::Align2::RIGHT_CENTER,
"now",
egui::FontId::monospace(9.0),
ONE_DARK_PRO.text_subtle,
);
}
fn paint_force_sweep(painter: &egui::Painter, chart_rect: egui::Rect, time: f32) {
let sweep = (time * 0.55).fract();
let x = chart_rect.left() + sweep * chart_rect.width();
let sweep_rect = egui::Rect::from_min_max(
egui::pos2(x - 8.0, chart_rect.top()),
egui::pos2(x + 8.0, chart_rect.bottom()),
);
painter.rect_filled(
sweep_rect,
egui::CornerRadius::ZERO,
color_alpha(ONE_DARK_PRO.accent, 20),
);
painter.line_segment(
[
egui::pos2(x, chart_rect.top()),
egui::pos2(x, chart_rect.bottom()),
],
egui::Stroke::new(1.0, color_alpha(ACCENT_GREEN, 95)),
);
}
fn paint_force_area(painter: &egui::Painter, points: &[egui::Pos2], plot_rect: egui::Rect) {
if points.len() < 2 {
return;
}
for pair in points.windows(2) {
let left = pair[0];
let right = pair[1];
let area = vec![
egui::pos2(left.x, plot_rect.bottom()),
left,
right,
egui::pos2(right.x, plot_rect.bottom()),
];
painter.add(egui::Shape::convex_polygon(
area,
color_alpha(ONE_DARK_PRO.accent, 32),
egui::Stroke::NONE,
));
}
}
fn color_alpha(color: egui::Color32, alpha: u8) -> egui::Color32 {
egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), alpha)
}
fn draw_floating_panel(
ctx: &egui::Context,
panel: &mut FloatingPanelState,