Files
eskin-player/src/app.rs
2026-06-29 03:13:57 +08:00

578 lines
20 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 crate::breakout::{BreakoutGame, control_from_matrix};
use crate::connection::ConnectionManager;
use crate::recording::Recorder;
use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
use crate::style::{ONE_DARK_PRO, apply_fonts, apply_theme, layout};
use crate::ui::SerialMode;
use crate::{
matrix::{MATRIX_COLS, MATRIX_ROWS},
render::{
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback,
},
ui::{
ConfigPanelState, ConnectPanelState, FloatingPanelState, MatrixConfigState,
draw_config_panel, draw_export_panel, draw_matrix_config_panel, draw_stats_panel,
panel_restore_item,
},
};
use eframe::{egui, egui_wgpu};
use std::sync::Arc;
const DATA_LOG_EVERY_FRAMES: u64 = 30;
const SUMMARY_POINTS_PER_SERIES: usize = 42;
const MIN_DISPLAY_FORCE_N: f32 = 0.1;
const MAX_DISPLAY_FORCE_N: f32 = 25.6;
pub struct EskinDesktopApp {
connect_panel: FloatingPanelState,
connect_state: ConnectPanelState,
connection: Arc<ConnectionManager>,
pressure_matrix: PressureFrame,
data_log_frame: u64,
scene_panel: FloatingPanelState,
config_panel: FloatingPanelState,
config_state: ConfigPanelState,
stats_panel: FloatingPanelState,
// New: recording, export, matrix config, signal charts
recorder: Recorder,
export_panel: FloatingPanelState,
export_path: String,
matrix_config_panel: FloatingPanelState,
matrix_config: MatrixConfigState,
signal_history: Vec<f32>,
latest_raw_matrix: Vec<u32>,
latest_matrix_rows: u32,
latest_matrix_cols: u32,
breakout_visible: bool,
breakout_game: BreakoutGame,
context_menu_open: bool,
context_menu_pos: egui::Pos2,
active_mode: ActiveMode,
}
impl EskinDesktopApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
egui_extras::install_image_loaders(&cc.egui_ctx);
apply_fonts(&cc.egui_ctx);
apply_theme(&cc.egui_ctx, &ONE_DARK_PRO);
let wgpu_state = cc
.wgpu_render_state
.as_ref()
.expect("need open eframe wgpu renderer feature");
let mut renderer = wgpu_state.renderer.write();
renderer
.callback_resources
.insert(BackgroundRenderResources::new(
&wgpu_state.device,
&wgpu_state.queue,
&wgpu_state.target_format,
MATRIX_ROWS,
MATRIX_COLS,
));
Self {
connect_panel: FloatingPanelState::new([0.0, 0.0], [layout::RIGHT_TAG_X, 48.0]),
connect_state: ConnectPanelState::default(),
connection: Arc::new(ConnectionManager::new()),
pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT],
data_log_frame: 0,
scene_panel: FloatingPanelState::new(
[layout::LEFT_X, layout::TOP_Y],
[layout::LEFT_TAG_X, layout::TOP_Y],
),
config_panel: FloatingPanelState::new(
[layout::RIGHT_X, layout::TOP_Y],
[layout::RIGHT_TAG_X, layout::TOP_Y],
),
config_state: ConfigPanelState::default(),
stats_panel: FloatingPanelState::new(
[layout::LEFT_X, layout::LEFT_SECOND_Y],
[layout::LEFT_TAG_X, layout::LEFT_SECOND_Y],
),
recorder: Recorder::full(),
export_panel: FloatingPanelState::new(
[layout::LEFT_X, layout::TOP_Y],
[layout::LEFT_TAG_X, layout::TOP_Y],
),
export_path: String::new(),
matrix_config_panel: FloatingPanelState::new(
[layout::RIGHT_X, layout::RIGHT_SECOND_Y],
[layout::RIGHT_TAG_X, layout::RIGHT_SECOND_Y],
),
matrix_config: MatrixConfigState::default(),
signal_history: Vec::with_capacity(128),
latest_raw_matrix: Vec::new(),
latest_matrix_rows: MATRIX_ROWS,
latest_matrix_cols: MATRIX_COLS,
breakout_visible: false,
breakout_game: BreakoutGame::default(),
context_menu_open: false,
context_menu_pos: egui::pos2(24.0, 48.0),
active_mode: ActiveMode::Finger(FingerMode {
rows: 12,
cols: 7,
range: 0..7000,
dot: true,
}),
}
}
fn draw_wgpu_background_rect(&self, ui: &mut egui::Ui, rect: egui::Rect) {
let width = rect.width().max(1.0);
let height = rect.height().max(1.0);
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
rect,
WgpuBackgroundCallback {
width,
height,
pressure: self.pressure_matrix,
active_mode: self.active_mode.clone(),
},
));
}
fn draw_workspace(&mut self, ui: &mut egui::Ui) {
if self.breakout_visible {
self.draw_split_workspace(ui);
} else {
self.draw_wgpu_background_rect(ui, ui.max_rect());
}
}
fn draw_split_workspace(&mut self, ui: &mut egui::Ui) {
let screen = ui.max_rect();
let content = egui::Rect::from_min_max(
screen.left_top() + egui::vec2(0.0, layout::TITLE_BAR_HEIGHT),
screen.right_bottom(),
);
let gutter = 10.0;
let half_width = (content.width() - gutter).max(1.0) * 0.5;
let left = egui::Rect::from_min_size(content.min, egui::vec2(half_width, content.height()));
let right = egui::Rect::from_min_max(
egui::pos2(left.right() + gutter, content.top()),
content.right_bottom(),
);
ui.painter()
.rect_filled(screen, egui::CornerRadius::ZERO, ONE_DARK_PRO.bg);
paint_split_viewport_shell(ui, left, "DOTMATRIX", "压力矩阵 / WGPU");
paint_split_viewport_shell(ui, right, "BREAKOUT", "压力控制打砖块");
let left_body = split_viewport_body(left);
let right_body = split_viewport_body(right);
self.draw_wgpu_background_rect(ui, left_body);
let breakout_control = control_from_matrix(
&self.latest_raw_matrix,
self.latest_matrix_rows,
self.latest_matrix_cols,
);
self.breakout_game.draw_viewport(
ui.ctx(),
right_body,
&mut self.breakout_visible,
breakout_control,
);
}
fn update_pressure_matrix(&mut self) {
if let Some(sample) = self.connection.take_latest_sample() {
normalize_pressure_sample(
&sample.matrix,
sample.rows,
sample.cols,
&mut self.pressure_matrix,
);
self.latest_raw_matrix.clear();
self.latest_raw_matrix.extend_from_slice(&sample.matrix);
self.latest_matrix_rows = sample.rows;
self.latest_matrix_cols = sample.cols;
// Feed data to recorder
self.recorder.add_frame(&sample.matrix);
// JE-Skin summary logic: sum all cells first, then convert raw summary to force.
let raw_total = sample
.matrix
.iter()
.fold(0_u64, |sum, value| sum + *value as u64)
.min(u32::MAX as u64) as u32;
let force = raw_to_g1(raw_total).min(MAX_DISPLAY_FORCE_N);
let force = if force <= MIN_DISPLAY_FORCE_N {
0.0
} else {
force
};
self.signal_history.push(force);
if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES {
self.signal_history.remove(0);
}
self.data_log_frame += 1;
if self.data_log_frame % DATA_LOG_EVERY_FRAMES == 0 {
log_pressure_sample(&sample.matrix, sample.rows, sample.cols);
}
}
}
fn draw_title_bar(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let title_bar_height = layout::TITLE_BAR_HEIGHT;
let title_bar_rect = ui
.allocate_space(egui::vec2(ui.available_width(), title_bar_height))
.1;
// Paint background
ui.painter().rect_filled(
title_bar_rect,
egui::CornerRadius::ZERO,
ONE_DARK_PRO.panel_deep,
);
ui.painter().line_segment(
[title_bar_rect.left_bottom(), title_bar_rect.right_bottom()],
egui::Stroke::new(1.0, ONE_DARK_PRO.border),
);
// Drag-to-move: double-click to maximize, drag to move
let title_bar_response = ui.interact(
title_bar_rect,
egui::Id::new("title_bar"),
egui::Sense::click_and_drag(),
);
if title_bar_response.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if title_bar_response.double_clicked() {
let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::Maximized(!is_maximized));
}
// macOS traffic light buttons (close / minimize / maximize)
let btn_size = 14.0;
let btn_spacing = 8.0;
let btns_start = title_bar_rect.left_center() + egui::vec2(12.0, 0.0);
let btn_close_center = btns_start;
let btn_min_center = btns_start + egui::vec2(btn_size + btn_spacing, 0.0);
let btn_max_center = btns_start + egui::vec2((btn_size + btn_spacing) * 2.0, 0.0);
// Close (red)
let close_rect =
egui::Rect::from_center_size(btn_close_center, egui::vec2(btn_size, btn_size));
let close_resp = ui.interact(close_rect, egui::Id::new("btn_close"), egui::Sense::click());
let close_color = egui::Color32::from_rgb(255, 95, 86);
ui.painter()
.circle_filled(btn_close_center, btn_size / 2.0, close_color);
// Draw × when hovered
if close_resp.hovered() {
ui.painter().line_segment(
[
btn_close_center + egui::vec2(-3.0, -3.0),
btn_close_center + egui::vec2(3.0, 3.0),
],
egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 0, 0)),
);
ui.painter().line_segment(
[
btn_close_center + egui::vec2(3.0, -3.0),
btn_close_center + egui::vec2(-3.0, 3.0),
],
egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 0, 0)),
);
}
if close_resp.clicked() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
// Minimize (yellow)
let min_rect = egui::Rect::from_center_size(btn_min_center, egui::vec2(btn_size, btn_size));
let min_resp = ui.interact(min_rect, egui::Id::new("btn_min"), egui::Sense::click());
let min_color = egui::Color32::from_rgb(255, 189, 46);
ui.painter()
.circle_filled(btn_min_center, btn_size / 2.0, min_color);
if min_resp.hovered() {
ui.painter().line_segment(
[
btn_min_center + egui::vec2(-3.0, 0.0),
btn_min_center + egui::vec2(3.0, 0.0),
],
egui::Stroke::new(1.5, egui::Color32::from_rgb(120, 80, 0)),
);
}
if min_resp.clicked() {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
}
// Maximize (green)
let max_rect = egui::Rect::from_center_size(btn_max_center, egui::vec2(btn_size, btn_size));
let max_resp = ui.interact(max_rect, egui::Id::new("btn_max"), egui::Sense::click());
let max_color = egui::Color32::from_rgb(39, 201, 63);
ui.painter()
.circle_filled(btn_max_center, btn_size / 2.0, max_color);
if max_resp.hovered() {
let s = 3.0;
ui.painter().rect_stroke(
egui::Rect::from_center_size(btn_max_center, egui::vec2(s * 2.0, s * 2.0)),
egui::CornerRadius::same(1),
egui::Stroke::new(1.5, egui::Color32::from_rgb(0, 80, 10)),
egui::StrokeKind::Outside,
);
}
if max_resp.clicked() {
let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::Maximized(!is_maximized));
}
}
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
// draw_scene_panel(ctx, &mut self.scene_panel);
if let Some(next_mode) = draw_config_panel(
ctx,
&mut self.config_panel,
&mut self.config_state,
&self.connection,
) {
self.switch_mode(next_mode);
}
draw_stats_panel(ctx, &mut self.stats_panel, &self.signal_history);
draw_export_panel(
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) {
let (secondary_clicked, pointer_pos, escape_pressed) = ctx.input(|input| {
(
input.pointer.secondary_clicked(),
input.pointer.interact_pos(),
input.key_pressed(egui::Key::Escape),
)
});
if secondary_clicked {
self.context_menu_open = true;
self.context_menu_pos = pointer_pos.unwrap_or(self.context_menu_pos);
}
if escape_pressed {
self.context_menu_open = false;
}
if !self.context_menu_open {
return;
}
let mut close_menu = false;
let response = egui::Area::new(egui::Id::new("workspace_context_menu_area"))
.fixed_pos(self.context_menu_pos)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(132.0);
ui.label("显示面板");
ui.separator();
panel_restore_item(ui, "配置", &mut self.config_panel);
panel_restore_item(ui, "录制", &mut self.export_panel);
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
panel_restore_item(ui, "统计", &mut self.stats_panel);
if ui.button("打砖块").clicked() {
self.breakout_visible = true;
close_menu = true;
}
ui.separator();
if ui.button("全部显示").clicked() {
self.config_panel.visible = true;
self.export_panel.visible = true;
self.matrix_config_panel.visible = true;
self.stats_panel.visible = true;
self.breakout_visible = true;
close_menu = true;
}
});
});
let clicked_outside = ctx.input(|input| {
input.pointer.primary_clicked()
&& input
.pointer
.interact_pos()
.is_some_and(|pos| !response.response.rect.contains(pos))
});
if close_menu || clicked_outside {
self.context_menu_open = false;
}
}
fn switch_mode(&mut self, next: SerialMode) {
self.connect_state.mode = next;
self.config_state.mode = next;
self.connection.disconnect();
self.pressure_matrix.fill([0.0, 0.0]);
self.data_log_frame = 0;
self.active_mode = match next {
SerialMode::Finger => ActiveMode::Finger(FingerMode {
rows: self.matrix_config.rows,
cols: self.matrix_config.cols,
range: 0..7000,
dot: true,
}),
SerialMode::Hand => ActiveMode::Hand(HandGatewayMode { range: 0..7000 }),
}
}
}
fn log_pressure_sample(raw: &[u32], rows: u32, cols: u32) {
let max = raw.iter().copied().max().unwrap_or(0);
let sum: u64 = raw.iter().map(|value| *value as u64).sum();
let non_zero = raw.iter().filter(|value| **value != 0).count();
let preview = raw
.iter()
.take(12)
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ");
println!(
"[pressure] {rows}x{cols} cells={} non_zero={non_zero} max={max} sum={sum} first=[{preview}]",
raw.len()
);
}
fn raw_to_g1(raw: u32) -> f32 {
const RAW: [u32; 12] = [
0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444,
];
const FORCE_CENTI_N: [f32; 12] = [
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0, 2557.0,
];
if raw <= RAW[0] {
return FORCE_CENTI_N[0] / 100.0;
}
if raw >= RAW[RAW.len() - 1] {
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
}
// Keep the same lookup behavior as JE-Skin so both apps report the same force.
let mut left = 0usize;
let mut right = RAW.len() - 1;
while left + 1 < right {
let mid = (left + right) / 2;
if RAW[mid] <= raw {
left = mid;
} else {
right = mid;
}
}
let raw_left = RAW[left] as f32;
let raw_right = RAW[right] as f32;
let span = (raw_right - raw_left).max(1.0);
let ratio = (raw as f32 - raw_left) / span;
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
}
fn paint_split_viewport_shell(
ui: &egui::Ui,
rect: egui::Rect,
title: &'static str,
subtitle: &'static str,
) {
let painter = ui.painter();
painter.rect_filled(
rect,
egui::CornerRadius::same(8),
egui::Color32::from_rgb(11, 17, 23),
);
painter.rect_stroke(
rect,
egui::CornerRadius::same(8),
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
egui::StrokeKind::Outside,
);
painter.line_segment(
[
rect.left_top() + egui::vec2(12.0, 34.0),
rect.right_top() + egui::vec2(-12.0, 34.0),
],
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
);
painter.text(
rect.left_top() + egui::vec2(14.0, 16.0),
egui::Align2::LEFT_CENTER,
title,
egui::FontId::monospace(13.0),
ONE_DARK_PRO.accent,
);
painter.text(
rect.left_top() + egui::vec2(122.0, 16.0),
egui::Align2::LEFT_CENTER,
subtitle,
egui::FontId::proportional(12.0),
ONE_DARK_PRO.text_dim,
);
}
fn split_viewport_body(rect: egui::Rect) -> egui::Rect {
egui::Rect::from_min_max(
rect.left_top() + egui::vec2(10.0, 44.0),
rect.right_bottom() - egui::vec2(10.0, 10.0),
)
}
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
const RANGE_MIN: f32 = 0.0;
const RANGE_MAX: f32 = 7000.0;
normalized.fill([0.0, 0.0]);
let src_cols = cols.max(1);
let copy_rows = MATRIX_ROWS.min(rows);
let copy_cols = MATRIX_COLS.min(cols);
for row in 0..copy_rows {
for col in 0..copy_cols {
let src_index = (row * src_cols + col) as usize;
let dst_index = (row * MATRIX_COLS + col) as usize;
if let Some(value) = raw.get(src_index) {
let raw_value = *value as f32;
let mapped = ((raw_value - RANGE_MIN) / (RANGE_MAX - RANGE_MIN)).clamp(0.0, 1.0);
let display_value = if raw_value <= RANGE_MIN + 4.0 {
0.0
} else {
raw_value.round().min(9999.0)
};
normalized[dst_index] = [mapped, display_value];
}
}
}
}
impl eframe::App for EskinDesktopApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.update_pressure_matrix();
self.draw_workspace(ui);
self.draw_title_bar(ui, frame);
self.draw_floating_panels(&ctx);
self.draw_panel_context_menu(&ctx);
// Keep repainting while the wgpu background is a realtime viewport.
ctx.request_repaint();
}
}