Add finger mode UI and refine breakout/serial integration

This commit is contained in:
lenn
2026-07-02 15:22:13 +08:00
parent 0a9fea0f0a
commit 71d314ac44
4 changed files with 352 additions and 90 deletions

View File

@@ -13,16 +13,19 @@ use crate::{
}, },
ui::{ ui::{
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState, ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
draw_config_panel, draw_export_panel, draw_hand_force_panels, draw_matrix_config_panel, draw_config_panel, draw_spatial_force_panel, draw_stats_panel, panel_restore_item,
draw_stats_panel, panel_restore_item,
}, },
}; };
use eframe::{egui, egui_wgpu}; use eframe::{egui, egui_wgpu};
use std::sync::Arc; use std::{
sync::Arc,
time::{Duration, Instant},
};
const SUMMARY_POINTS_PER_SERIES: usize = 42; const SUMMARY_POINTS_PER_SERIES: usize = 42;
const HAND_FORCE_PANEL_COUNT: usize = 7; const HAND_FORCE_PANEL_COUNT: usize = 7;
const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [84, 84, 84, 84, 84, 70, 44]; const HAND_FORCE_SEGMENT_COUNTS: [usize; HAND_FORCE_PANEL_COUNT] = [84, 84, 84, 84, 84, 70, 44];
const RATE_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
pub struct EskinDesktopApp { pub struct EskinDesktopApp {
connect_panel: FloatingPanelState, connect_panel: FloatingPanelState,
@@ -44,16 +47,61 @@ pub struct EskinDesktopApp {
hand_signal_histories: [Vec<f32>; HAND_FORCE_PANEL_COUNT], hand_signal_histories: [Vec<f32>; HAND_FORCE_PANEL_COUNT],
force_estimator: ForceEstimatorState, force_estimator: ForceEstimatorState,
latest_spatial_force: Option<HudSpatialForce>, latest_spatial_force: Option<HudSpatialForce>,
spatial_force_panel_force: Option<HudSpatialForce>,
latest_raw_matrix: Vec<u32>, latest_raw_matrix: Vec<u32>,
latest_matrix_rows: u32, latest_matrix_rows: u32,
latest_matrix_cols: u32, latest_matrix_cols: u32,
breakout_visible: bool, breakout_visible: bool,
breakout_game: BreakoutGame, breakout_game: BreakoutGame,
live_rates: LiveRateMeters,
context_menu_open: bool, context_menu_open: bool,
context_menu_pos: egui::Pos2, context_menu_pos: egui::Pos2,
active_mode: ActiveMode, active_mode: ActiveMode,
} }
struct LiveRateMeters {
window_started_at: Instant,
window_rx_frames: u64,
window_render_frames: u32,
sample_rate_hz: f32,
render_rate_hz: f32,
}
impl LiveRateMeters {
fn new() -> Self {
Self {
window_started_at: Instant::now(),
window_rx_frames: 0,
window_render_frames: 0,
sample_rate_hz: 0.0,
render_rate_hz: 0.0,
}
}
fn tick_render(&mut self, total_rx_frames: u64) {
if total_rx_frames < self.window_rx_frames {
self.window_rx_frames = total_rx_frames;
self.sample_rate_hz = 0.0;
}
self.window_render_frames = self.window_render_frames.saturating_add(1);
let now = Instant::now();
let elapsed = now.duration_since(self.window_started_at);
if elapsed < RATE_UPDATE_INTERVAL {
return;
}
let seconds = elapsed.as_secs_f32().max(0.001);
let rx_delta = total_rx_frames.saturating_sub(self.window_rx_frames);
self.sample_rate_hz = rx_delta as f32 / seconds;
self.render_rate_hz = self.window_render_frames as f32 / seconds;
self.window_started_at = now;
self.window_rx_frames = total_rx_frames;
self.window_render_frames = 0;
}
}
impl EskinDesktopApp { impl EskinDesktopApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self { pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
egui_extras::install_image_loaders(&cc.egui_ctx); egui_extras::install_image_loaders(&cc.egui_ctx);
@@ -110,11 +158,13 @@ impl EskinDesktopApp {
hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)), hand_signal_histories: std::array::from_fn(|_| Vec::with_capacity(128)),
force_estimator: ForceEstimatorState::new(), force_estimator: ForceEstimatorState::new(),
latest_spatial_force: None, latest_spatial_force: None,
spatial_force_panel_force: None,
latest_raw_matrix: Vec::new(), latest_raw_matrix: Vec::new(),
latest_matrix_rows: MATRIX_ROWS, latest_matrix_rows: MATRIX_ROWS,
latest_matrix_cols: MATRIX_COLS, latest_matrix_cols: MATRIX_COLS,
breakout_visible: false, breakout_visible: false,
breakout_game: BreakoutGame::default(), breakout_game: BreakoutGame::default(),
live_rates: LiveRateMeters::new(),
context_menu_open: false, context_menu_open: false,
context_menu_pos: egui::pos2(24.0, 48.0), context_menu_pos: egui::pos2(24.0, 48.0),
active_mode: ActiveMode::Finger(FingerMode { active_mode: ActiveMode::Finger(FingerMode {
@@ -362,13 +412,22 @@ impl EskinDesktopApp {
} }
} }
fn draw_floating_panels(&mut self, ctx: &egui::Context) { fn draw_floating_panels(
// draw_scene_panel(ctx, &mut self.scene_panel); &mut self,
// if self.config_state.mode == SerialMode::Hand && self.stats_panel.visible { ctx: &egui::Context,
// self.config_panel.visible = false; stats: crate::serial_core::serial::SerialIoStats,
// self.export_panel.visible = false; ) {
// self.matrix_config_panel.visible = false; self.scene_panel.visible = false;
// } self.connect_panel.visible = false;
self.export_panel.visible = false;
self.matrix_config_panel.visible = false;
self.context_menu_open = false;
if self.breakout_visible {
self.config_panel.visible = false;
self.stats_panel.visible = false;
return;
}
if let Some(next_mode) = draw_config_panel( if let Some(next_mode) = draw_config_panel(
ctx, ctx,
@@ -376,29 +435,43 @@ impl EskinDesktopApp {
&mut self.config_state, &mut self.config_state,
&self.connection, &self.connection,
&self.recorder, &self.recorder,
stats,
self.live_rates.sample_rate_hz,
self.live_rates.render_rate_hz,
) { ) {
if next_mode == SerialMode::Hand {
self.config_state.mode = SerialMode::Finger;
self.connect_state.mode = SerialMode::Finger;
self.config_state.baud_rate = SerialMode::Finger.baud_rate();
self.active_mode = ActiveMode::Finger(FingerMode {
rows: self.matrix_config.rows,
cols: self.matrix_config.cols,
range: 0..7000,
dot: true,
});
self.breakout_visible = true;
} else {
self.switch_mode(next_mode); self.switch_mode(next_mode);
} }
match self.config_state.mode { }
SerialMode::Finger => {
if self.config_state.mode == SerialMode::Finger {
self.stats_panel.visible = true;
draw_stats_panel( draw_stats_panel(
ctx, ctx,
&mut self.stats_panel, &mut self.stats_panel,
&self.signal_history, &self.signal_history,
self.latest_spatial_force, self.latest_spatial_force,
); );
} else {
self.stats_panel.visible = false;
} }
SerialMode::Hand => {
draw_hand_force_panels(ctx, self.stats_panel.visible, &self.hand_signal_histories); if let Some(force) = self.latest_spatial_force {
self.spatial_force_panel_force = Some(force);
} }
}
draw_export_panel( draw_spatial_force_panel(ctx, self.spatial_force_panel_force, &self.signal_history);
ctx,
&mut self.export_panel,
&self.recorder,
&mut self.export_path,
);
draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config);
} }
fn draw_panel_context_menu(&mut self, ctx: &egui::Context) { fn draw_panel_context_menu(&mut self, ctx: &egui::Context) {
@@ -499,6 +572,7 @@ impl EskinDesktopApp {
self.hand_pressure.clear(); self.hand_pressure.clear();
self.force_estimator.reset(); self.force_estimator.reset();
self.latest_spatial_force = None; self.latest_spatial_force = None;
self.spatial_force_panel_force = None;
self.signal_history.clear(); self.signal_history.clear();
self.hand_signal_histories self.hand_signal_histories
.iter_mut() .iter_mut()
@@ -663,12 +737,13 @@ fn normalize_pressure_value(value: u32) -> [f32; 2] {
impl eframe::App for EskinDesktopApp { impl eframe::App for EskinDesktopApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let stats = self.connection.stats();
self.live_rates.tick_render(stats.rx_frames);
self.update_pressure_matrix(); self.update_pressure_matrix();
self.draw_workspace(ui); self.draw_workspace(ui);
self.draw_title_bar(ui, frame); self.draw_title_bar(ui, frame);
self.draw_floating_panels(&ctx); self.draw_floating_panels(&ctx, stats);
self.draw_panel_context_menu(&ctx);
// Keep repainting while the wgpu background is a realtime viewport. // Keep repainting while the wgpu background is a realtime viewport.
ctx.request_repaint(); ctx.request_repaint();

View File

@@ -20,6 +20,7 @@ enum BreakoutPhase {
Idle, Idle,
Running, Running,
Paused, Paused,
Won,
Over, Over,
} }
@@ -46,7 +47,6 @@ pub struct BreakoutGame {
score: u32, score: u32,
combo: u32, combo: u32,
lives: u32, lives: u32,
level: u32,
last_time: Option<f64>, last_time: Option<f64>,
previous_pause_gesture: bool, previous_pause_gesture: bool,
pause_locked_until: f64, pause_locked_until: f64,
@@ -63,7 +63,6 @@ impl Default for BreakoutGame {
score: 0, score: 0,
combo: 0, combo: 0,
lives: 3, lives: 3,
level: 1,
last_time: None, last_time: None,
previous_pause_gesture: false, previous_pause_gesture: false,
pause_locked_until: 0.0, pause_locked_until: 0.0,
@@ -150,7 +149,6 @@ impl BreakoutGame {
status_chip(ui, "分数", self.score.to_string(), ACCENT_GREEN); status_chip(ui, "分数", self.score.to_string(), ACCENT_GREEN);
status_chip(ui, "连击", self.combo.to_string(), ACCENT_ORANGE); status_chip(ui, "连击", self.combo.to_string(), ACCENT_ORANGE);
status_chip(ui, "生命", self.lives.to_string(), ACCENT_RED); status_chip(ui, "生命", self.lives.to_string(), ACCENT_RED);
status_chip(ui, "关卡", self.level.to_string(), ACCENT_BLUE);
}); });
ui.add_space(7.0); ui.add_space(7.0);
@@ -191,12 +189,11 @@ impl BreakoutGame {
self.score = 0; self.score = 0;
self.combo = 0; self.combo = 0;
self.lives = 3; self.lives = 3;
self.level = 1;
self.rebuild_bricks(); self.rebuild_bricks();
} }
fn start(&mut self) { fn start(&mut self) {
if self.phase == BreakoutPhase::Over { if self.phase == BreakoutPhase::Over || self.phase == BreakoutPhase::Won {
self.reset(); self.reset();
} }
if self.phase != BreakoutPhase::Running { if self.phase != BreakoutPhase::Running {
@@ -219,7 +216,10 @@ impl BreakoutGame {
let threshold = pressure_pause_threshold(); let threshold = pressure_pause_threshold();
let active = top_force >= threshold; let active = top_force >= threshold;
if active && !self.previous_pause_gesture && now >= self.pause_locked_until { if active && !self.previous_pause_gesture && now >= self.pause_locked_until {
if self.phase == BreakoutPhase::Idle || self.phase == BreakoutPhase::Over { if matches!(
self.phase,
BreakoutPhase::Idle | BreakoutPhase::Over | BreakoutPhase::Won
) {
self.start(); self.start();
} else { } else {
self.toggle_pause(); self.toggle_pause();
@@ -249,7 +249,7 @@ impl BreakoutGame {
} }
fn launch_ball(&mut self) { fn launch_ball(&mut self) {
let direction = if self.level % 2 == 0 { -0.24 } else { 0.24 }; let direction = 0.24;
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS); self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
self.ball_vel = egui::vec2(direction, -1.0).normalized() * BALL_SPEED; self.ball_vel = egui::vec2(direction, -1.0).normalized() * BALL_SPEED;
} }
@@ -324,9 +324,8 @@ impl BreakoutGame {
} }
if self.bricks.iter().all(|brick| !brick.alive) { if self.bricks.iter().all(|brick| !brick.alive) {
self.level += 1; self.phase = BreakoutPhase::Won;
self.rebuild_bricks(); self.ball_vel = egui::Vec2::ZERO;
self.launch_ball();
} }
} }
@@ -358,6 +357,7 @@ impl BreakoutGame {
BreakoutPhase::Idle => "待机", BreakoutPhase::Idle => "待机",
BreakoutPhase::Running => "运行", BreakoutPhase::Running => "运行",
BreakoutPhase::Paused => "暂停", BreakoutPhase::Paused => "暂停",
BreakoutPhase::Won => "过关",
BreakoutPhase::Over => "结束", BreakoutPhase::Over => "结束",
} }
} }
@@ -387,6 +387,7 @@ impl BreakoutGame {
if self.phase == BreakoutPhase::Idle if self.phase == BreakoutPhase::Idle
|| self.phase == BreakoutPhase::Paused || self.phase == BreakoutPhase::Paused
|| self.phase == BreakoutPhase::Won
|| self.phase == BreakoutPhase::Over || self.phase == BreakoutPhase::Over
{ {
paint_center_overlay(&painter, rect, self.phase); paint_center_overlay(&painter, rect, self.phase);
@@ -403,6 +404,10 @@ pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl
let cols = cols.max(1) as usize; let cols = cols.max(1) as usize;
let sample_rows = rows.min(2); let sample_rows = rows.min(2);
let sample_cols = cols.min(2); let sample_cols = cols.min(2);
let top_gesture_rows = rows.min(1);
let top_gesture_cols = (cols / 3).clamp(1, cols);
let top_gesture_col_start = (cols - top_gesture_cols) / 2;
let top_gesture_col_end = top_gesture_col_start + top_gesture_cols;
let avg = |row_start: usize, row_end: usize, col_start: usize, col_end: usize| -> f32 { let avg = |row_start: usize, row_end: usize, col_start: usize, col_end: usize| -> f32 {
let mut sum = 0.0; let mut sum = 0.0;
let mut count = 0.0; let mut count = 0.0;
@@ -430,7 +435,12 @@ pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl
let left_force = tl + bl; let left_force = tl + bl;
let right_force = tr + br; let right_force = tr + br;
let top_force = tl + tr; let top_force = avg(
0,
top_gesture_rows,
top_gesture_col_start,
top_gesture_col_end,
);
let span = 1200.0_f32.max((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN) * 0.22); let span = 1200.0_f32.max((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN) * 0.22);
let raw_axis = ((right_force - left_force) / span).clamp(-1.0, 1.0); let raw_axis = ((right_force - left_force) / span).clamp(-1.0, 1.0);
let axis = if raw_axis.abs() < 0.045 { let axis = if raw_axis.abs() < 0.045 {
@@ -485,6 +495,7 @@ fn status_color(phase: BreakoutPhase) -> egui::Color32 {
BreakoutPhase::Idle => ONE_DARK_PRO.text_dim, BreakoutPhase::Idle => ONE_DARK_PRO.text_dim,
BreakoutPhase::Running => ACCENT_GREEN, BreakoutPhase::Running => ACCENT_GREEN,
BreakoutPhase::Paused => ACCENT_ORANGE, BreakoutPhase::Paused => ACCENT_ORANGE,
BreakoutPhase::Won => ACCENT_GREEN,
BreakoutPhase::Over => ACCENT_RED, BreakoutPhase::Over => ACCENT_RED,
} }
} }
@@ -607,6 +618,7 @@ fn paint_center_overlay(painter: &egui::Painter, rect: egui::Rect, phase: Breako
let (title, detail) = match phase { let (title, detail) = match phase {
BreakoutPhase::Idle => ("按压顶部或点击开始", "左右分区压力控制挡板"), BreakoutPhase::Idle => ("按压顶部或点击开始", "左右分区压力控制挡板"),
BreakoutPhase::Paused => ("已暂停", "再次按压顶部、P 或点击暂停继续"), BreakoutPhase::Paused => ("已暂停", "再次按压顶部、P 或点击暂停继续"),
BreakoutPhase::Won => ("恭喜过关", "按压顶部、点击或空格重新开始"),
BreakoutPhase::Over => ("游戏结束", "点击或空格重开"), BreakoutPhase::Over => ("游戏结束", "点击或空格重开"),
BreakoutPhase::Running => ("", ""), BreakoutPhase::Running => ("", ""),
}; };

View File

@@ -14,6 +14,7 @@ const DEFAULT_HAND_GATEWAY_NODE_SAMPLE_COUNTS: &[u16] = &[84, 84, 84, 84, 84, 70
pub struct SerialIoStats { pub struct SerialIoStats {
pub rx_bytes: u64, pub rx_bytes: u64,
pub tx_bytes: u64, pub tx_bytes: u64,
pub rx_frames: u64,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -101,6 +102,8 @@ fn run_tactile_a_loop(
recorder.add_frame(&pressures); recorder.add_frame(&pressures);
} }
let _ = sample_tx.try_send(vals); let _ = sample_tx.try_send(vals);
io_stats.rx_frames += 1;
publish_stats(stats_tx, io_stats);
} }
} }
} }
@@ -191,6 +194,8 @@ fn run_hand_gateway_loop(
recorder.add_frame(&pressures); recorder.add_frame(&pressures);
} }
let _ = sample_tx.try_send(vals); let _ = sample_tx.try_send(vals);
io_stats.rx_frames += 1;
publish_stats(stats_tx, io_stats);
} }
} }
} }

262
src/ui.rs
View File

@@ -391,17 +391,20 @@ pub fn draw_config_panel(
config: &mut ConfigPanelState, config: &mut ConfigPanelState,
connection: &ConnectionManager, connection: &ConnectionManager,
recorder: &Recorder, recorder: &Recorder,
stats: SerialIoStats,
sample_rate_hz: f32,
render_rate_hz: f32,
) -> Option<SerialMode> { ) -> Option<SerialMode> {
let mut changed_mode = None; let mut changed_mode = None;
let conn_state = connection.state(); let conn_state = connection.state();
let stats = connection.stats();
let panel_width = responsive_panel_width(ctx, 560.0, 340.0); let panel_width = responsive_panel_width(ctx, 560.0, 340.0);
config.connected = matches!( config.connected = matches!(
conn_state, conn_state,
ConnectionState::Connected | ConnectionState::Streaming ConnectionState::Connected | ConnectionState::Streaming
); );
panel.visible = true;
draw_floating_panel(ctx, panel, "配置", "config_panel", 560.0, 340.0, |ui| { draw_pinned_panel(ctx, panel, "配置", "config_panel", 560.0, 340.0, |ui| {
ui.set_min_width(panel_width); ui.set_min_width(panel_width);
ui.set_max_width(panel_width); ui.set_max_width(panel_width);
@@ -416,11 +419,85 @@ pub fn draw_config_panel(
// Legacy serial parameter grid is intentionally kept commented for future hardware: // Legacy serial parameter grid is intentionally kept commented for future hardware:
// draw_serial_grid(ui, config); // draw_serial_grid(ui, config);
// ui.add_space(8.0); // ui.add_space(8.0);
draw_mode_body(ui, config, conn_state, stats); draw_mode_body(
ui,
config,
conn_state,
stats,
sample_rate_hz,
render_rate_hz,
);
}); });
changed_mode 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> { fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option<SerialMode> {
let mut changed_to = None; let mut changed_to = None;
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
@@ -430,7 +507,7 @@ fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option<Ser
if mode_button(ui, &mut config.mode, SerialMode::Finger, "指尖模块") { if mode_button(ui, &mut config.mode, SerialMode::Finger, "指尖模块") {
changed_to = Some(SerialMode::Finger) changed_to = Some(SerialMode::Finger)
} }
if mode_button(ui, &mut config.mode, SerialMode::Hand, "手掌模块") { if mode_button(ui, &mut config.mode, SerialMode::Hand, "游戏体验") {
changed_to = Some(SerialMode::Hand) changed_to = Some(SerialMode::Hand)
} }
// mode_button(ui, &mut config.mode, SerialMode::Model, "模型"); // mode_button(ui, &mut config.mode, SerialMode::Model, "模型");
@@ -609,47 +686,22 @@ fn draw_mode_body(
config: &mut ConfigPanelState, config: &mut ConfigPanelState,
conn_state: ConnectionState, conn_state: ConnectionState,
stats: SerialIoStats, stats: SerialIoStats,
sample_rate_hz: f32,
render_rate_hz: f32,
) { ) {
group_frame().show(ui, |ui| match config.mode { let _ = (config.mode, conn_state, stats);
SerialMode::Finger => { draw_realtime_rate_row(ui, sample_rate_hz, render_rate_hz);
// Legacy module-address controls for older devices:
// ui.horizontal(|ui| {
// ui.label(style::field_label("模块地址"));
// ui.add_sized(
// egui::vec2(80.0, METRICS.field_height),
// egui::DragValue::new(&mut config.module_addr).range(1..=247),
// );
// ui.add_space(16.0);
// if ui.add(tag_button("读取信息")).clicked() {}
// if ui.add(tag_button("探测")).clicked() {}
// });
draw_status_bytes_row(ui, conn_state, stats);
} }
SerialMode::Hand => {
// Legacy manual-TX controls for older hand-module debugging: fn draw_realtime_rate_row(ui: &mut egui::Ui, sample_rate_hz: f32, render_rate_hz: f32) {
// ui.horizontal(|ui| { group_frame().show(ui, |ui| {
// ui.label(style::field_label("发送")); ui.horizontal_wrapped(|ui| {
// ui.add_sized( ui.colored_label(dim_text(), "实时采样率");
// egui::vec2(300.0, METRICS.field_height), ui.label(style::value_text(format!("{sample_rate_hz:.1} Hz")));
// egui::TextEdit::singleline(&mut config.manual_tx), ui.add_space(18.0);
// ); ui.colored_label(dim_text(), "画面刷新率");
// if ui.add(tag_button("发送")).clicked() {} ui.label(style::value_text(format!("{render_rate_hz:.1} Hz")));
// if ui.add(tag_button("清空")).clicked() { });
// config.manual_tx.clear();
// }
// });
draw_status_bytes_row(ui, conn_state, stats);
} // SerialMode::Model => {
// ui.horizontal(|ui| {
// ui.label(style::field_label("模型"));
// ui.add_sized(
// egui::vec2(300.0, METRICS.field_height),
// egui::TextEdit::singleline(&mut config.model_path),
// );
// if ui.add(tag_button("加载")).clicked() {}
// if ui.add(tag_button("运行")).clicked() {}
// });
// }
}); });
} }
@@ -1015,9 +1067,7 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32], chart_height:
.ctx() .ctx()
.animate_bool(egui::Id::new("resultant_force_chart_enter"), true); .animate_bool(egui::Id::new("resultant_force_chart_enter"), true);
let slide_offset = (1.0 - enter) * -chart_rect.width() * 0.65; let slide_offset = (1.0 - enter) * -chart_rect.width() * 0.65;
let tick_phase = (time / FORCE_CHART_SAMPLE_SECONDS).fract(); let plot_rect = chart_rect.translate(egui::vec2(slide_offset, 0.0));
let scroll_offset = -tick_phase * chart_rect.width() / CHART_POINTS.max(1) as f32;
let plot_rect = chart_rect.translate(egui::vec2(slide_offset + scroll_offset, 0.0));
let mut points = Vec::with_capacity(values.len()); let mut points = Vec::with_capacity(values.len());
let start_slot = CHART_POINTS.saturating_sub(values.len()); let start_slot = CHART_POINTS.saturating_sub(values.len());
@@ -1144,6 +1194,126 @@ fn color_alpha(color: egui::Color32, alpha: u8) -> egui::Color32 {
egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), alpha) 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( fn draw_floating_panel(
ctx: &egui::Context, ctx: &egui::Context,
panel: &mut FloatingPanelState, panel: &mut FloatingPanelState,