feat: 添加 README,更新 .gitignore,移除 JE-Skin/eskin-finger-sdk

- 添加 README.md 项目文档
- 更新 .gitignore 排除 JE-Skin/、eskin-finger-sdk/ 及构建产物
- 从 git 跟踪中移除 JE-Skin 和 eskin-finger-sdk

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
lennlouisgeek
2026-05-20 03:15:16 +08:00
parent d2c9fad556
commit 5f1c217853
19 changed files with 628 additions and 116 deletions

9
.gitignore vendored
View File

@@ -1 +1,10 @@
/target
JE-Skin/
eskin-finger-sdk/
*.err
*.out
*.exe
*.pdb
*.d
*.rlib
*.rmeta

Submodule JE-Skin deleted from 59e9203363

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# Eskin Model Player
实时压力矩阵可视化桌面应用,用于连接 E-Skin 传感器设备并通过串口接收压力数据,以热力图方式实时渲染。
## 功能
- 串口连接 E-Skin 传感器921600 baud
- 实时压力矩阵热力图渲染wgpu
- 自定义无边框窗口macOS 风格标题栏
- 浮动面板:连接管理、场景视图、配置、数据统计
## 依赖
- Rust 2024 edition
- [eframe](https://crates.io/crates/eframe) 0.34egui + wgpu
- [serialport](https://crates.io/crates/serialport) 4.9
## 构建与运行
```bash
cargo run --release
```
## 项目结构
```
src/
├── main.rs # 入口,创建 eframe 窗口
├── app.rs # 应用主循环与面板调度
├── connection.rs # 串口连接管理(后台线程)
├── serial_core/ # 串口协议编解码
│ ├── serial.rs # 串口读写循环
│ ├── codec.rs # 帧编解码器
│ ├── frame.rs # 帧结构定义
│ └── ...
├── render.rs # wgpu 渲染管线(背景 + 数字叠加)
├── matrix.rs # 矩阵布局与坐标变换
├── ui.rs # egui 浮动面板 UI
├── theme.rs # 深色工程主题
├── shader.wgsl # WGSL 着色器
└── utils.rs # 工具函数
```

Submodule eskin-finger-sdk deleted from aa1b312290

View File

@@ -5,7 +5,9 @@ use crate::connection::ConnectionManager;
use crate::theme::{ENGINEERING_DARK, apply_fonts, apply_theme};
use crate::{
matrix::{MATRIX_COLS, MATRIX_ROWS},
render::{BackgroundRenderResources, PRESSURE_CELL_COUNT, WgpuBackgroundCallback},
render::{
BackgroundRenderResources, PRESSURE_CELL_COUNT, PressureFrame, WgpuBackgroundCallback,
},
ui::{
ConfigPanelState, ConnectPanelState, FloatingPanelState, draw_config_panel,
draw_connect_panel, draw_scene_panel, draw_stats_panel,
@@ -18,7 +20,7 @@ pub struct EskinDesktopApp {
connect_panel: FloatingPanelState,
connect_state: ConnectPanelState,
connection: Arc<ConnectionManager>,
pressure_matrix: [f32; PRESSURE_CELL_COUNT],
pressure_matrix: PressureFrame,
data_log_frame: u64,
scene_panel: FloatingPanelState,
config_panel: FloatingPanelState,
@@ -51,7 +53,7 @@ impl EskinDesktopApp {
connect_panel: FloatingPanelState::new([0.0, 0.0], [0.0, 0.0]),
connect_state: ConnectPanelState::default(),
connection: Arc::new(ConnectionManager::new()),
pressure_matrix: [0.0; PRESSURE_CELL_COUNT],
pressure_matrix: [[0.0, 0.0]; PRESSURE_CELL_COUNT],
data_log_frame: 0,
scene_panel: FloatingPanelState::new([16.0, 48.0], [16.0, 48.0]),
config_panel: FloatingPanelState::new([840.0, 48.0], [128.0, 48.0]),
@@ -92,15 +94,115 @@ impl EskinDesktopApp {
}
}
fn draw_toolbar(&mut self, ui: &mut egui::Ui) {
egui::Panel::top("main_menu").show_inside(ui, |ui| {
ui.horizontal(|ui| {
ui.checkbox(&mut self.connect_panel.visible, "连接");
ui.checkbox(&mut self.scene_panel.visible, "场景");
ui.checkbox(&mut self.config_panel.visible, "配置");
ui.checkbox(&mut self.stats_panel.visible, "统计");
});
});
fn draw_title_bar(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let title_bar_height = 36.0;
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,
ENGINEERING_DARK.panel_deep,
);
ui.painter().line_segment(
[title_bar_rect.left_bottom(), title_bar_rect.right_bottom()],
egui::Stroke::new(1.0, ENGINEERING_DARK.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) {
@@ -133,18 +235,11 @@ fn log_pressure_sample(raw: &[u32], rows: u32, cols: u32) {
);
}
fn normalize_pressure_sample(
raw: &[u32],
rows: u32,
cols: u32,
normalized: &mut [f32; PRESSURE_CELL_COUNT],
) {
normalized.fill(0.0);
let max_value = raw.iter().copied().max().unwrap_or(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;
if max_value == 0 {
return;
}
normalized.fill([0.0, 0.0]);
let src_cols = cols.max(1);
let copy_rows = MATRIX_ROWS.min(rows);
@@ -155,18 +250,25 @@ fn normalize_pressure_sample(
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) {
normalized[dst_index] = (*value as f32 / max_value as f32).clamp(0.0, 1.0);
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) {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.draw_wgpu_background(ui);
self.draw_toolbar(ui);
self.draw_title_bar(ui, frame);
self.draw_floating_panels(&ctx);
// Keep repainting while the wgpu background is a realtime viewport.

View File

@@ -4,7 +4,7 @@ use std::time::Duration;
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
use crate::serial_core::serial::{run_serial_loop, SerialPortReadWrite};
use crate::serial_core::serial::{SerialPortReadWrite, run_serial_loop};
/// Connection state visible to the UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -115,11 +115,7 @@ impl ConnectionManager {
let rows = 12u32;
let cols = 7u32;
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
last = Some(PressureSample {
matrix,
rows,
cols,
});
last = Some(PressureSample { matrix, rows, cols });
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
@@ -174,11 +170,7 @@ fn run_device_loop(
Ok(vals) => {
// Store latest
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
*latest.lock().unwrap() = Some(PressureSample {
matrix,
rows,
cols,
});
*latest.lock().unwrap() = Some(PressureSample { matrix, rows, cols });
// Forward
let _ = outer_tx.try_send(vals);
}

View File

@@ -16,7 +16,8 @@ fn main() -> eframe::Result<()> {
renderer: eframe::Renderer::Wgpu,
viewport: egui::ViewportBuilder::default()
.with_inner_size([1920.0, 1080.0])
.with_min_inner_size([1280.0, 720.0]),
.with_min_inner_size([1280.0, 720.0])
.with_decorations(false),
..Default::default()
};

View File

@@ -11,8 +11,6 @@ const MIN_BOARD_PADDING: f32 = 2.6;
const MAX_BOARD_PADDING: f32 = 6.8;
const MATRIX_OFFSET_Y: f32 = -2.4;
const MATRIX_OFFSET_Z: f32 = 12.0;
const HEIGHT_SCALE: f32 = 10.6;
const BASE_HEIGHT: f32 = 0.12;
const CAMERA_FOV: f32 = 36.0;
const CAMERA_DISTANCE_MIN: f32 = 30.0;
const CAMERA_DISTANCE_MAX: f32 = 122.0;
@@ -87,14 +85,13 @@ pub fn glyph_world_position(
pressure: f32,
) -> ([f32; 4], f32) {
let normalized = pressure.clamp(0.0, 1.0);
let height = BASE_HEIGHT + normalized.powf(0.9) * HEIGHT_SCALE;
let x = (col as f32 - cols as f32 / 2.0 + 0.5) * layout.cell_spacing;
let z = (row as f32 - rows as f32 / 2.0 + 0.5) * layout.cell_spacing;
(
[
x,
MATRIX_OFFSET_Y + height + layout.label_float_offset,
MATRIX_OFFSET_Y + layout.label_float_offset,
MATRIX_OFFSET_Z + z,
1.0,
],

View File

@@ -8,11 +8,12 @@ use crate::matrix::{MatrixLayout, build_view_projection, glyph_world_position};
pub const PRESSURE_CELL_COUNT: usize =
(crate::matrix::MATRIX_ROWS * crate::matrix::MATRIX_COLS) as usize;
pub type PressureFrame = [[f32; 2]; PRESSURE_CELL_COUNT];
pub struct WgpuBackgroundCallback {
pub width: f32,
pub height: f32,
pub pressure: [f32; PRESSURE_CELL_COUNT],
pub pressure: PressureFrame,
}
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
@@ -126,7 +127,8 @@ impl BackgroundRenderResources {
usage: wgpu::BufferUsages::VERTEX,
});
let glyph_instances = build_glyph_instances(rows, cols, &layout, &[]);
let glyph_instances =
build_glyph_instances(rows, cols, &layout, &[[0.0, 0.0]; PRESSURE_CELL_COUNT]);
let glyph_instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Pressure Glyph Instance Buffer"),
contents: bytemuck::cast_slice(&glyph_instances),
@@ -148,7 +150,7 @@ impl BackgroundRenderResources {
}
}
fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, pressure: &[f32]) {
fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, pressure: &PressureFrame) {
let aspect = width / height.max(1.0);
self.uniform =
MatrixUniform::new(width, height, build_view_projection(aspect, &self.layout));
@@ -255,19 +257,19 @@ fn build_glyph_instances(
rows: u32,
cols: u32,
layout: &MatrixLayout,
pressure: &[f32],
pressure: &PressureFrame,
) -> Vec<GlyphInstance> {
let mut instances = Vec::with_capacity((rows * cols) as usize);
for row in 0..rows {
for col in 0..cols {
let index = (row * cols + col) as usize;
let normalized = pressure.get(index).copied().unwrap_or(0.0);
let [normalized, display_value] = pressure.get(index).copied().unwrap_or([0.0, 0.0]);
let (world_position, normalized) =
glyph_world_position(row, col, rows, cols, layout, normalized);
instances.push(GlyphInstance {
world_position,
style: [normalized, 0.0, 0.0, 0.0],
style: [normalized, display_value, 0.0, 0.0],
});
}
}
@@ -280,17 +282,17 @@ fn update_glyph_instances(
rows: u32,
cols: u32,
layout: &MatrixLayout,
pressure: &[f32],
pressure: &PressureFrame,
) {
for row in 0..rows {
for col in 0..cols {
let index = (row * cols + col) as usize;
let normalized = pressure.get(index).copied().unwrap_or(0.0);
let [normalized, display_value] = pressure.get(index).copied().unwrap_or([0.0, 0.0]);
let (world_position, normalized) =
glyph_world_position(row, col, rows, cols, layout, normalized);
if let Some(instance) = instances.get_mut(index) {
instance.world_position = world_position;
instance.style = [normalized, 0.0, 0.0, 0.0];
instance.style = [normalized, display_value, 0.0, 0.0];
}
}
}

View File

@@ -30,11 +30,7 @@ impl TactileACodec {
.chunks_exact(2)
.map(|chunk| {
let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32;
if raw < 15 {
0
} else {
raw
}
if raw < 15 { 0 } else { raw }
})
.collect::<Vec<i32>>();

View File

@@ -25,6 +25,7 @@ struct GlyphVertexOutput {
@builtin(position) clip_position: vec4f,
@location(0) local: vec2f,
@location(1) intensity: f32,
@location(2) display_value: f32,
}
fn saturate(value: f32) -> f32 {
@@ -43,25 +44,27 @@ fn rotate2(point: vec2f, angle: f32) -> vec2f {
return vec2f(point.x * c - point.y * s, point.x * s + point.y * c);
}
fn rect_alpha(point: vec2f, center: vec2f, half_size: vec2f) -> f32 {
let delta = abs(point - center) - half_size;
let outside = length(max(delta, vec2f(0.0, 0.0)));
let inside = min(max(delta.x, delta.y), 0.0);
let dist = outside + inside;
return 1.0 - smoothstep(0.015, 0.045, dist);
}
fn range_stop_color(index: u32) -> vec3f {
switch index {
case 0u: {
return vec3f(0.263, 0.667, 0.420);
return vec3f(0.140, 0.690, 0.890);
}
case 1u: {
return vec3f(0.094, 0.735, 0.875);
return vec3f(0.250, 0.760, 0.380);
}
case 2u: {
return vec3f(0.298, 0.349, 0.957);
}
case 3u: {
return vec3f(0.812, 0.294, 0.851);
}
case 4u: {
return vec3f(0.957, 0.337, 0.333);
return vec3f(1.000, 0.670, 0.180);
}
default: {
return vec3f(1.000, 0.596, 0.204);
return vec3f(1.000, 0.255, 0.190);
}
}
}
@@ -69,43 +72,115 @@ fn range_stop_color(index: u32) -> vec3f {
fn sample_range_color(value: f32) -> vec3f {
let t = saturate(value);
if (t <= 0.25) {
let local = smoothstep(0.0, 0.25, t);
if (t <= 0.33) {
let local = smoothstep(0.0, 0.33, t);
return mix(range_stop_color(0u), range_stop_color(1u), local);
}
if (t <= 0.5) {
let local = smoothstep(0.25, 0.5, t);
if (t <= 0.66) {
let local = smoothstep(0.33, 0.66, t);
return mix(range_stop_color(1u), range_stop_color(2u), local);
}
if (t <= 0.75) {
let local = smoothstep(0.5, 0.75, t);
let local = smoothstep(0.66, 1.0, t);
return mix(range_stop_color(2u), range_stop_color(3u), local);
}
if (t <= 0.875) {
let local = smoothstep(0.75, 0.875, t);
return mix(range_stop_color(3u), range_stop_color(4u), local);
fn digit_segment_on(digit: u32, segment: u32) -> bool {
switch digit {
case 0u: { return segment != 6u; }
case 1u: { return segment == 1u || segment == 2u; }
case 2u: { return segment == 0u || segment == 1u || segment == 6u || segment == 4u || segment == 3u; }
case 3u: { return segment == 0u || segment == 1u || segment == 6u || segment == 2u || segment == 3u; }
case 4u: { return segment == 5u || segment == 6u || segment == 1u || segment == 2u; }
case 5u: { return segment == 0u || segment == 5u || segment == 6u || segment == 2u || segment == 3u; }
case 6u: { return segment == 0u || segment == 5u || segment == 4u || segment == 3u || segment == 2u || segment == 6u; }
case 7u: { return segment == 0u || segment == 1u || segment == 2u; }
case 8u: { return true; }
default: { return segment == 0u || segment == 1u || segment == 2u || segment == 3u || segment == 5u || segment == 6u; }
}
}
let local = smoothstep(0.875, 1.0, t);
return mix(range_stop_color(4u), range_stop_color(5u), local);
fn seven_segment_digit_alpha(local: vec2f, digit: u32) -> f32 {
var alpha = 0.0;
if (digit_segment_on(digit, 0u)) {
alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.70), vec2f(0.38, 0.078)));
}
if (digit_segment_on(digit, 1u)) {
alpha = max(alpha, rect_alpha(local, vec2f(0.39, 0.36), vec2f(0.078, 0.335)));
}
if (digit_segment_on(digit, 2u)) {
alpha = max(alpha, rect_alpha(local, vec2f(0.39, -0.36), vec2f(0.078, 0.335)));
}
if (digit_segment_on(digit, 3u)) {
alpha = max(alpha, rect_alpha(local, vec2f(0.0, -0.70), vec2f(0.38, 0.078)));
}
if (digit_segment_on(digit, 4u)) {
alpha = max(alpha, rect_alpha(local, vec2f(-0.39, -0.36), vec2f(0.078, 0.335)));
}
if (digit_segment_on(digit, 5u)) {
alpha = max(alpha, rect_alpha(local, vec2f(-0.39, 0.36), vec2f(0.078, 0.335)));
}
if (digit_segment_on(digit, 6u)) {
alpha = max(alpha, rect_alpha(local, vec2f(0.0, 0.0), vec2f(0.35, 0.075)));
}
return alpha;
}
fn digit_zero_alpha(local: vec2f) -> f32 {
let tilted = rotate2(local, -0.18);
let oval = vec2f(tilted.x / 0.48, tilted.y / 0.75);
let aa = 0.030;
let outer = 1.0 - smoothstep(0.93 - aa, 0.93 + aa, length(oval));
let inner = 1.0 - smoothstep(0.57 - aa, 0.57 + aa, length(oval));
let ring = saturate(outer - inner);
let slash = 1.0 - smoothstep(
0.036,
0.066,
capsule_distance(tilted, vec2f(-0.16, 0.43), vec2f(0.18, -0.43)),
);
return max(ring, slash * 0.88);
fn digit_count(value: u32) -> u32 {
if (value >= 1000u) {
return 4u;
}
if (value >= 100u) {
return 3u;
}
if (value >= 10u) {
return 2u;
}
return 1u;
}
fn digit_at(value: u32, slot: u32, count: u32) -> u32 {
if (count == 4u) {
switch slot {
case 0u: { return (value / 1000u) % 10u; }
case 1u: { return (value / 100u) % 10u; }
case 2u: { return (value / 10u) % 10u; }
default: { return value % 10u; }
}
}
if (count == 3u) {
switch slot {
case 0u: { return (value / 100u) % 10u; }
case 1u: { return (value / 10u) % 10u; }
default: { return value % 10u; }
}
}
if (count == 2u) {
return select(value % 10u, (value / 10u) % 10u, slot == 0u);
}
return value % 10u;
}
fn number_alpha(local: vec2f, display_value: f32) -> f32 {
let value = min(u32(max(display_value + 0.5, 0.0)), 9999u);
let count = digit_count(value);
let count_f = f32(count);
let slot_width = 1.74 / count_f;
let start_x = -slot_width * (count_f - 1.0) * 0.5;
var alpha = 0.0;
for (var slot = 0u; slot < 4u; slot = slot + 1u) {
if (slot < count) {
let center_x = start_x + f32(slot) * slot_width;
let digit_local = vec2f((local.x - center_x) / (slot_width * 0.78), local.y / 0.92);
let digit = digit_at(value, slot, count);
let in_slot = step(abs(local.x - center_x), slot_width * 0.48);
alpha = max(alpha, seven_segment_digit_alpha(digit_local, digit) * in_slot);
}
}
return alpha;
}
@vertex
@@ -149,8 +224,8 @@ fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f {
let tick_area = step(abs(local.x), half_size.x) * step(abs(local.y), half_size.y + 8.0);
if (tick_area > 0.5) {
let ticks = array<f32, 6>(0.0, 0.25, 0.5, 0.75, 0.875, 1.0);
for (var index = 0u; index < 6u; index = index + 1u) {
let ticks = array<f32, 4>(0.0, 0.33, 0.66, 1.0);
for (var index = 0u; index < 4u; index = index + 1u) {
let tick_x = (ticks[index] - 0.5) * track_width;
let tick = step(abs(local.x - tick_x), 1.0) * step(abs(local.y), half_size.y + 7.0);
color = max(color, vec3f(0.78, 0.84, 0.90) * tick);
@@ -163,19 +238,21 @@ fn fs_background(@builtin(position) frag_coord: vec4f) -> @location(0) vec4f {
@vertex
fn vs_glyph(vertex: GlyphVertexInput, instance: GlyphInstanceInput) -> GlyphVertexOutput {
let center = u.view_proj * vec4f(instance.world_position.xyz, 1.0);
let pixel_size = u.glyph.x * mix(0.92, 1.10, saturate(instance.style.x));
let shaped = pow(saturate(instance.style.x), 0.9);
let pixel_size = u.glyph.x * mix(1.08, 2.20, shaped);
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
var out: GlyphVertexOutput;
out.clip_position = vec4f(center.xy + ndc_offset * center.w, center.z, center.w);
out.local = vertex.local;
out.intensity = instance.style.x;
out.display_value = instance.style.y;
return out;
}
@fragment
fn fs_glyph(in: GlyphVertexOutput) -> @location(0) vec4f {
let alpha = digit_zero_alpha(in.local);
let color = sample_range_color(in.intensity) * 1.08;
let alpha = number_alpha(in.local, in.display_value);
let color = sample_range_color(in.intensity) * mix(0.82, 1.16, saturate(in.intensity));
return vec4f(color, alpha);
}

306
src/ui.rs
View File

@@ -10,6 +10,9 @@ 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 {
@@ -63,6 +66,9 @@ impl FloatingPanelState {
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,
}
}
}
@@ -131,7 +137,7 @@ pub fn draw_connect_panel(
ConnectionState::Connected | ConnectionState::Streaming
);
draw_center_floating_panel(ctx, panel, "connect_center_panel", 64.0, |ui| {
draw_center_floating_panel(ctx, panel, "connect_center_panel", 42.0, "连接", |ui| {
ui.set_min_width(320.0);
ui.vertical(|ui| {
@@ -654,18 +660,308 @@ fn draw_center_floating_panel(
panel: &mut FloatingPanelState,
id: &'static str,
top_offset: f32,
collapsed_label: &'static str,
add_contents: impl FnOnce(&mut egui::Ui),
) {
if !panel.visible {
const PANEL_WIDTH: f32 = 430.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))
.order(egui::Order::Foreground)
.anchor(
egui::Align2::CENTER_TOP,
egui::vec2(0.0, top_offset - (1.0 - eased) * SLIDE_DISTANCE),
)
.order(egui::Order::Tooltip)
.show(ctx, |ui| {
panel_frame(ctx).show(ui, |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> {
egui::Frame::new()
.fill(ENGINEERING_DARK.panel)
.corner_radius(egui::CornerRadius::same(ENGINEERING_DARK.radius))
.inner_margin(egui::Margin::symmetric(10, 8))
.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 {
ENGINEERING_DARK.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, ENGINEERING_DARK.border),
));
ui.painter().line_segment(
[points[2], points[3]],
egui::Stroke::new(1.0, ENGINEERING_DARK.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 {
ENGINEERING_DARK.accent_hot
} else {
ENGINEERING_DARK.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),
ENGINEERING_DARK.text,
)
});
ui.painter().galley(
egui::pos2(rect.left() + 14.0, center.y - galley.size().y * 0.5 - 1.0),
galley,
ENGINEERING_DARK.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, ENGINEERING_DARK.border);
let accent_stroke = egui::Stroke::new(1.2, ENGINEERING_DARK.border_soft);
let top = rect.top();
let left = rect.left();
let right = rect.right();
let bottom = rect.bottom();
let radius = ENGINEERING_DARK.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));
}