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

264
src/ui.rs
View File

@@ -391,17 +391,20 @@ pub fn draw_config_panel(
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 stats = connection.stats();
let panel_width = responsive_panel_width(ctx, 560.0, 340.0);
config.connected = matches!(
conn_state,
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_max_width(panel_width);
@@ -416,11 +419,85 @@ pub fn draw_config_panel(
// 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);
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| {
@@ -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, "指尖模块") {
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)
}
// mode_button(ui, &mut config.mode, SerialMode::Model, "模型");
@@ -609,47 +686,22 @@ fn draw_mode_body(
config: &mut ConfigPanelState,
conn_state: ConnectionState,
stats: SerialIoStats,
sample_rate_hz: f32,
render_rate_hz: f32,
) {
group_frame().show(ui, |ui| match config.mode {
SerialMode::Finger => {
// 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:
// 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("模型"));
// 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() {}
// });
// }
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")));
});
});
}
@@ -1015,9 +1067,7 @@ fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32], chart_height:
.ctx()
.animate_bool(egui::Id::new("resultant_force_chart_enter"), true);
let slide_offset = (1.0 - enter) * -chart_rect.width() * 0.65;
let tick_phase = (time / FORCE_CHART_SAMPLE_SECONDS).fract();
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 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());
@@ -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)
}
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,