Wire live serial data into matrix renderer
This commit is contained in:
27
Cargo.lock
generated
27
Cargo.lock
generated
@@ -870,6 +870,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -879,6 +894,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -1272,11 +1296,14 @@ version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
"crc",
|
||||
"crossbeam-channel",
|
||||
"eframe",
|
||||
"egui_extras",
|
||||
"env_logger",
|
||||
"glam",
|
||||
"image",
|
||||
"log",
|
||||
"serialport",
|
||||
]
|
||||
|
||||
|
||||
@@ -12,3 +12,6 @@ image = { version = "0.25.10", features = ["png", "jpeg"] }
|
||||
anyhow = "1.0.102"
|
||||
serialport = "4.9.0"
|
||||
egui_extras = { version = "0.34.2", features = ["image"] }
|
||||
crossbeam-channel = "0.5.15"
|
||||
crc = "3.4.0"
|
||||
log = "0.4.29"
|
||||
|
||||
Submodule eskin-finger-sdk updated: 705375085f...aa1b312290
87
src/app.rs
87
src/app.rs
@@ -1,25 +1,29 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::{egui, egui_wgpu};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::connection::ConnectionManager;
|
||||
use crate::theme::{ENGINEERING_DARK, apply_fonts, apply_theme};
|
||||
use crate::{
|
||||
matrix::{MATRIX_COLS, MATRIX_ROWS},
|
||||
render::{BackgroundRenderResources, WgpuBackgroundCallback},
|
||||
render::{BackgroundRenderResources, PRESSURE_CELL_COUNT, WgpuBackgroundCallback},
|
||||
ui::{
|
||||
ConfigPanelState, ConnectPanelState, FloatingPanelState, draw_config_panel,
|
||||
draw_connect_panel, draw_scene_panel, draw_stats_panel,
|
||||
},
|
||||
};
|
||||
|
||||
const DATA_LOG_EVERY_FRAMES: u64 = 30;
|
||||
|
||||
pub struct EskinDesktopApp {
|
||||
connect_panel: FloatingPanelState,
|
||||
connect_state: ConnectPanelState,
|
||||
connection: Arc<ConnectionManager>,
|
||||
pressure_matrix: [f32; PRESSURE_CELL_COUNT],
|
||||
data_log_frame: u64,
|
||||
scene_panel: FloatingPanelState,
|
||||
config_panel: FloatingPanelState,
|
||||
config_state: ConfigPanelState,
|
||||
stats_panel: FloatingPanelState,
|
||||
started_at: Instant,
|
||||
}
|
||||
|
||||
impl EskinDesktopApp {
|
||||
@@ -46,15 +50,19 @@ impl EskinDesktopApp {
|
||||
Self {
|
||||
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],
|
||||
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]),
|
||||
config_state: ConfigPanelState::default(),
|
||||
stats_panel: FloatingPanelState::new([16.0, 520.0], [240.0, 48.0]),
|
||||
started_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) {
|
||||
self.update_pressure_matrix();
|
||||
|
||||
let rect = ui.max_rect();
|
||||
let width = rect.width().max(1.0);
|
||||
let height = rect.height().max(1.0);
|
||||
@@ -64,11 +72,26 @@ impl EskinDesktopApp {
|
||||
WgpuBackgroundCallback {
|
||||
width,
|
||||
height,
|
||||
time: self.started_at.elapsed().as_secs_f32(),
|
||||
pressure: self.pressure_matrix,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
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.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_toolbar(&mut self, ui: &mut egui::Ui) {
|
||||
egui::Panel::top("main_menu").show_inside(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -81,13 +104,63 @@ impl EskinDesktopApp {
|
||||
}
|
||||
|
||||
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
|
||||
draw_connect_panel(ctx, &mut self.connect_panel, &mut self.connect_state);
|
||||
draw_connect_panel(
|
||||
ctx,
|
||||
&mut self.connect_panel,
|
||||
&mut self.connect_state,
|
||||
&self.connection,
|
||||
);
|
||||
draw_scene_panel(ctx, &mut self.scene_panel);
|
||||
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state);
|
||||
draw_stats_panel(ctx, &mut self.stats_panel);
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
|
||||
if max_value == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
normalized[dst_index] = (*value as f32 / max_value as f32).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for EskinDesktopApp {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
|
||||
197
src/connection.rs
Normal file
197
src/connection.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
|
||||
|
||||
use crate::serial_core::serial::{run_serial_loop, SerialPortReadWrite};
|
||||
|
||||
/// Connection state visible to the UI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnectionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Streaming,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// A pressure sample forwarded to the render layer.
|
||||
pub struct PressureSample {
|
||||
pub matrix: Vec<u32>,
|
||||
pub rows: u32,
|
||||
pub cols: u32,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
cancel_tx: Sender<()>,
|
||||
handle: JoinHandle<()>,
|
||||
sample_rx: Receiver<Vec<i32>>,
|
||||
}
|
||||
|
||||
/// Thread-safe connection manager that the UI and renderer can share.
|
||||
pub struct ConnectionManager {
|
||||
state: Arc<Mutex<ConnectionState>>,
|
||||
session: Arc<Mutex<Option<Session>>>,
|
||||
latest_sample: Arc<Mutex<Option<PressureSample>>>,
|
||||
}
|
||||
|
||||
impl ConnectionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(ConnectionState::Disconnected)),
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
latest_sample: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> ConnectionState {
|
||||
*self.state.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_state(&self, new_state: ConnectionState) {
|
||||
*self.state.lock().unwrap() = new_state;
|
||||
}
|
||||
|
||||
/// Connect to the given serial port and start streaming in a background thread.
|
||||
pub fn connect(&self, port_name: &str, rows: u32, cols: u32) {
|
||||
self.disconnect();
|
||||
self.set_state(ConnectionState::Connecting);
|
||||
|
||||
let port = port_name.to_owned();
|
||||
let state = Arc::clone(&self.state);
|
||||
let session_guard = Arc::clone(&self.session);
|
||||
let latest_sample = Arc::clone(&self.latest_sample);
|
||||
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1);
|
||||
let (sample_tx, sample_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let result = run_device_loop(
|
||||
&port,
|
||||
rows,
|
||||
cols,
|
||||
&state,
|
||||
&cancel_rx,
|
||||
&sample_tx,
|
||||
&latest_sample,
|
||||
);
|
||||
if let Err(e) = result {
|
||||
eprintln!("[connection] device loop error: {e}");
|
||||
*state.lock().unwrap() = ConnectionState::Error;
|
||||
}
|
||||
});
|
||||
|
||||
*session_guard.lock().unwrap() = Some(Session {
|
||||
cancel_tx,
|
||||
handle,
|
||||
sample_rx,
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnect from the device, stopping the background thread.
|
||||
pub fn disconnect(&self) {
|
||||
let session = {
|
||||
let mut guard = self.session.lock().unwrap();
|
||||
guard.take()
|
||||
};
|
||||
|
||||
if let Some(session) = session {
|
||||
let _ = session.cancel_tx.send(());
|
||||
let _ = session.handle.join();
|
||||
}
|
||||
|
||||
self.set_state(ConnectionState::Disconnected);
|
||||
*self.latest_sample.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Drain pending samples (non-blocking) and return the last one.
|
||||
pub fn take_latest_sample(&self) -> Option<PressureSample> {
|
||||
let session = self.session.lock().unwrap();
|
||||
if let Some(ref session) = *session {
|
||||
let mut last = None;
|
||||
loop {
|
||||
match session.sample_rx.try_recv() {
|
||||
Ok(vals) => {
|
||||
let rows = 12u32;
|
||||
let cols = 7u32;
|
||||
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
|
||||
last = Some(PressureSample {
|
||||
matrix,
|
||||
rows,
|
||||
cols,
|
||||
});
|
||||
}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
if last.is_some() {
|
||||
return last;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConnectionManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking device loop that runs on a background thread.
|
||||
fn run_device_loop(
|
||||
port_name: &str,
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
state: &Arc<Mutex<ConnectionState>>,
|
||||
cancel_rx: &Receiver<()>,
|
||||
sample_tx: &Sender<Vec<i32>>,
|
||||
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let port = serialport::new(port_name, 921_600)
|
||||
.timeout(Duration::from_millis(100))
|
||||
.open()?;
|
||||
|
||||
*state.lock().unwrap() = ConnectionState::Connected;
|
||||
|
||||
let mut rw = SerialPortReadWrite::new(port);
|
||||
*state.lock().unwrap() = ConnectionState::Streaming;
|
||||
|
||||
// We need to also forward samples to latest_sample
|
||||
let (inner_tx, inner_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
|
||||
let latest = Arc::clone(latest_sample);
|
||||
let outer_tx = sample_tx.clone();
|
||||
|
||||
// Bridge thread: reads from inner channel, forwards to both sample_tx and latest_sample
|
||||
let bridge_cancel = cancel_rx.clone();
|
||||
let bridge_handle = thread::spawn(move || {
|
||||
loop {
|
||||
if bridge_cancel.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
match inner_rx.try_recv() {
|
||||
Ok(vals) => {
|
||||
// Store latest
|
||||
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
|
||||
*latest.lock().unwrap() = Some(PressureSample {
|
||||
matrix,
|
||||
rows,
|
||||
cols,
|
||||
});
|
||||
// Forward
|
||||
let _ = outer_tx.try_send(vals);
|
||||
}
|
||||
Err(TryRecvError::Empty) => {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
run_serial_loop(&mut rw, rows as usize, cols as usize, cancel_rx, &inner_tx);
|
||||
|
||||
let _ = bridge_handle.join();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
mod app;
|
||||
mod connection;
|
||||
mod matrix;
|
||||
mod render;
|
||||
mod serial_core;
|
||||
mod theme;
|
||||
mod ui;
|
||||
mod utils;
|
||||
|
||||
@@ -84,9 +84,9 @@ pub fn glyph_world_position(
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
layout: &MatrixLayout,
|
||||
time: f32,
|
||||
pressure: f32,
|
||||
) -> ([f32; 4], f32) {
|
||||
let normalized = demo_pressure(row, col, time);
|
||||
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;
|
||||
@@ -102,14 +102,6 @@ pub fn glyph_world_position(
|
||||
)
|
||||
}
|
||||
|
||||
fn demo_pressure(row: u32, col: u32, time: f32) -> f32 {
|
||||
let seed = ((row * 17 + col * 31) as f32).sin() * 43_758.547;
|
||||
let phase = seed.fract() * std::f32::consts::TAU;
|
||||
let slow = 0.5 + 0.5 * (time * 1.35 + phase).sin();
|
||||
let row_wave = 0.5 + 0.5 * (time * 0.82 + row as f32 * 0.54 + col as f32 * 0.19).sin();
|
||||
(slow * row_wave).powf(0.72).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn fit_camera_distance(board_width: f32, board_depth: f32, board_padding: f32, aspect: f32) -> f32 {
|
||||
let padded_width = board_width + board_padding * 2.0;
|
||||
let padded_depth = board_depth + board_padding * 2.0;
|
||||
|
||||
@@ -6,10 +6,13 @@ use eframe::{
|
||||
|
||||
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 struct WgpuBackgroundCallback {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub time: f32,
|
||||
pub pressure: [f32; PRESSURE_CELL_COUNT],
|
||||
}
|
||||
|
||||
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
|
||||
@@ -22,7 +25,7 @@ impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
|
||||
resources: &mut egui_wgpu::CallbackResources,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
let resources: &mut BackgroundRenderResources = resources.get_mut().unwrap();
|
||||
resources.prepare(queue, self.width, self.height, self.time);
|
||||
resources.prepare(queue, self.width, self.height, &self.pressure);
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
@@ -123,7 +126,7 @@ impl BackgroundRenderResources {
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
|
||||
let glyph_instances = build_glyph_instances(rows, cols, &layout, 0.0);
|
||||
let glyph_instances = build_glyph_instances(rows, cols, &layout, &[]);
|
||||
let glyph_instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Pressure Glyph Instance Buffer"),
|
||||
contents: bytemuck::cast_slice(&glyph_instances),
|
||||
@@ -145,7 +148,7 @@ impl BackgroundRenderResources {
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, time: f32) {
|
||||
fn prepare(&mut self, queue: &wgpu::Queue, width: f32, height: f32, pressure: &[f32]) {
|
||||
let aspect = width / height.max(1.0);
|
||||
self.uniform =
|
||||
MatrixUniform::new(width, height, build_view_projection(aspect, &self.layout));
|
||||
@@ -155,7 +158,13 @@ impl BackgroundRenderResources {
|
||||
bytemuck::cast_slice(&[self.uniform]),
|
||||
);
|
||||
|
||||
self.glyph_instances = build_glyph_instances(self.rows, self.cols, &self.layout, time);
|
||||
update_glyph_instances(
|
||||
&mut self.glyph_instances,
|
||||
self.rows,
|
||||
self.cols,
|
||||
&self.layout,
|
||||
pressure,
|
||||
);
|
||||
queue.write_buffer(
|
||||
&self.glyph_instance_buffer,
|
||||
0,
|
||||
@@ -246,14 +255,16 @@ fn build_glyph_instances(
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
layout: &MatrixLayout,
|
||||
time: f32,
|
||||
pressure: &[f32],
|
||||
) -> 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 (world_position, normalized) =
|
||||
glyph_world_position(row, col, rows, cols, layout, time);
|
||||
glyph_world_position(row, col, rows, cols, layout, normalized);
|
||||
instances.push(GlyphInstance {
|
||||
world_position,
|
||||
style: [normalized, 0.0, 0.0, 0.0],
|
||||
@@ -264,6 +275,27 @@ fn build_glyph_instances(
|
||||
instances
|
||||
}
|
||||
|
||||
fn update_glyph_instances(
|
||||
instances: &mut [GlyphInstance],
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
layout: &MatrixLayout,
|
||||
pressure: &[f32],
|
||||
) {
|
||||
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 (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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct MatrixUniform {
|
||||
|
||||
7
src/serial_core/codec.rs
Normal file
7
src/serial_core/codec.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use crate::serial_core::error::CodecError;
|
||||
use std::time::Instant;
|
||||
|
||||
pub trait Codec<F> {
|
||||
fn decode(&mut self, input: &[u8], session_started_at: Instant) -> Result<Vec<F>, CodecError>;
|
||||
fn encode(&self, frame: &F) -> Result<Vec<u8>, CodecError>;
|
||||
}
|
||||
1
src/serial_core/codecs/mod.rs
Normal file
1
src/serial_core/codecs/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod tactile_a;
|
||||
188
src/serial_core/codecs/tactile_a.rs
Normal file
188
src/serial_core/codecs/tactile_a.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use crate::serial_core::codec::Codec;
|
||||
use crate::serial_core::error::CodecError;
|
||||
use crate::serial_core::frame::{
|
||||
TactileAFrame, TactileAFrameMetaData, TactileAFrameStatusCode, TactileARepFrame,
|
||||
TactileAReqFrame,
|
||||
};
|
||||
use crate::serial_core::utils::{calc_crc8_itu, elapsed_millis};
|
||||
|
||||
const FRAME_BUFFER_MIN_LENGTH: usize = 15;
|
||||
|
||||
pub struct TactileACodec {
|
||||
buffer: Vec<u8>,
|
||||
expected_data_len: usize,
|
||||
}
|
||||
|
||||
impl TactileACodec {
|
||||
pub fn new(cols: usize, rows: usize) -> TactileACodec {
|
||||
Self {
|
||||
buffer: Vec::new(),
|
||||
expected_data_len: cols * rows * 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_data_frame(data: &[u8]) -> Result<Vec<i32>, CodecError> {
|
||||
if data.len() % 2 != 0 {
|
||||
return Err(CodecError::InvalidLength);
|
||||
}
|
||||
|
||||
let vals: Vec<i32> = data
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| {
|
||||
let raw = u16::from_le_bytes([chunk[0], chunk[1]]) as i32;
|
||||
if raw < 15 {
|
||||
0
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
})
|
||||
.collect::<Vec<i32>>();
|
||||
|
||||
Ok(vals)
|
||||
}
|
||||
|
||||
pub fn build_req_frame(cols: usize, rows: usize) -> TactileAFrame {
|
||||
let header = [0x55, 0xAA];
|
||||
let payload_len: usize = 9;
|
||||
let device_addr: u8 = 0x34;
|
||||
let extend_code: u8 = 0x00;
|
||||
let func_code: u8 = 0xFB;
|
||||
let start_addr: u32 = 7168;
|
||||
let except_data_len: usize = cols * rows * 2;
|
||||
let checksum: u8 = 0;
|
||||
TactileAFrame::Req(TactileAReqFrame {
|
||||
meta: TactileAFrameMetaData {
|
||||
header,
|
||||
payload_len,
|
||||
device_addr,
|
||||
extend_code,
|
||||
func_code,
|
||||
start_addr,
|
||||
except_data_len,
|
||||
checksum,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Codec<TactileAFrame> for TactileACodec {
|
||||
fn decode(
|
||||
&mut self,
|
||||
input: &[u8],
|
||||
session_started_at: std::time::Instant,
|
||||
) -> Result<Vec<TactileAFrame>, CodecError> {
|
||||
self.buffer.extend_from_slice(input);
|
||||
let mut frames: Vec<TactileAFrame> = Vec::new();
|
||||
|
||||
loop {
|
||||
if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH {
|
||||
break;
|
||||
}
|
||||
|
||||
// Search for response header: [0xAA, 0x55]
|
||||
let header_pos = self.buffer.windows(2).position(|w| w == [0xAA, 0x55]);
|
||||
|
||||
let Some(pos) = header_pos else {
|
||||
self.buffer.clear();
|
||||
break;
|
||||
};
|
||||
if pos > 0 {
|
||||
self.buffer.drain(0..pos);
|
||||
}
|
||||
|
||||
if self.buffer.len() < FRAME_BUFFER_MIN_LENGTH {
|
||||
break;
|
||||
}
|
||||
|
||||
let header = [self.buffer[0], self.buffer[1]];
|
||||
let payload_len = u16::from_le_bytes([self.buffer[2], self.buffer[3]]) as usize;
|
||||
let device_addr = self.buffer[4];
|
||||
let extend_code = self.buffer[5];
|
||||
let func_code = self.buffer[6];
|
||||
let start_addr = u32::from_le_bytes([
|
||||
self.buffer[7],
|
||||
self.buffer[8],
|
||||
self.buffer[9],
|
||||
self.buffer[10],
|
||||
]);
|
||||
let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize;
|
||||
let status = match self.buffer[13] {
|
||||
0 => TactileAFrameStatusCode::Success,
|
||||
_ => TactileAFrameStatusCode::Failure,
|
||||
};
|
||||
|
||||
if except_data_len != self.expected_data_len {
|
||||
log::debug!(
|
||||
"unexpected payload length: expected {}, got {}, buffer_len={}",
|
||||
self.expected_data_len,
|
||||
except_data_len,
|
||||
self.buffer.len()
|
||||
);
|
||||
self.buffer.drain(0..1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let frame_length = except_data_len + FRAME_BUFFER_MIN_LENGTH;
|
||||
if self.buffer.len() < frame_length {
|
||||
break;
|
||||
}
|
||||
|
||||
let need_check_data = self.buffer[0..14 + except_data_len].to_vec();
|
||||
let payload = self.buffer[14..14 + except_data_len].to_vec();
|
||||
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
|
||||
let checksum = crc8_itu_alg.checksum(&need_check_data.as_slice());
|
||||
if self.buffer[frame_length - 1] != checksum {
|
||||
log::debug!(
|
||||
"checksum mismatch: expected {:02X}, got {:02X}, frame_len={}",
|
||||
checksum,
|
||||
self.buffer[frame_length - 1],
|
||||
frame_length
|
||||
);
|
||||
self.buffer.drain(0..1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let dts_ms = elapsed_millis(session_started_at);
|
||||
let meta = TactileAFrameMetaData {
|
||||
header,
|
||||
payload_len,
|
||||
device_addr,
|
||||
extend_code,
|
||||
func_code,
|
||||
start_addr,
|
||||
except_data_len,
|
||||
checksum,
|
||||
};
|
||||
frames.push(TactileAFrame::Rep(TactileARepFrame {
|
||||
meta,
|
||||
status,
|
||||
payload,
|
||||
dts_ms,
|
||||
}));
|
||||
|
||||
self.buffer.drain(0..frame_length);
|
||||
}
|
||||
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn encode(&self, frame: &TactileAFrame) -> Result<Vec<u8>, CodecError> {
|
||||
match frame {
|
||||
TactileAFrame::Req(f) => {
|
||||
let mut req_bytes: Vec<u8> = Vec::new();
|
||||
req_bytes.extend_from_slice(f.meta.header.as_slice());
|
||||
req_bytes.extend_from_slice((f.meta.payload_len as u16).to_le_bytes().as_slice());
|
||||
req_bytes.push(f.meta.device_addr);
|
||||
req_bytes.push(f.meta.extend_code);
|
||||
req_bytes.push(f.meta.func_code);
|
||||
req_bytes.extend_from_slice(f.meta.start_addr.to_le_bytes().as_slice());
|
||||
req_bytes
|
||||
.extend_from_slice((f.meta.except_data_len as u16).to_le_bytes().as_slice());
|
||||
let checksum = calc_crc8_itu(req_bytes.as_slice());
|
||||
req_bytes.push(checksum);
|
||||
Ok(req_bytes)
|
||||
}
|
||||
_ => Err(CodecError::InvalidFrameType),
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/serial_core/error.rs
Normal file
51
src/serial_core/error.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SerialError {
|
||||
OpenError,
|
||||
CloseError,
|
||||
ScanError,
|
||||
InvalidConfig,
|
||||
AlreadyConnected,
|
||||
StateError,
|
||||
NoRecordedData,
|
||||
}
|
||||
|
||||
impl fmt::Display for SerialError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
SerialError::OpenError => write!(f, "Opening Error"),
|
||||
SerialError::CloseError => write!(f, "Closing Error"),
|
||||
SerialError::ScanError => write!(f, "Scan Error"),
|
||||
SerialError::InvalidConfig => write!(f, "Invalid Config"),
|
||||
SerialError::AlreadyConnected => write!(f, "Already Connected"),
|
||||
SerialError::StateError => write!(f, "State Error"),
|
||||
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SerialError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CodecError {
|
||||
InvalidHeader,
|
||||
InvalidTail,
|
||||
InvalidLength,
|
||||
InvalidFrameType,
|
||||
PayloadTooLarge,
|
||||
}
|
||||
|
||||
impl fmt::Display for CodecError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
CodecError::InvalidHeader => write!(f, "Invalid Header"),
|
||||
CodecError::InvalidTail => write!(f, "Invalid Tail"),
|
||||
CodecError::InvalidLength => write!(f, "Invalid Length"),
|
||||
CodecError::InvalidFrameType => write!(f, "Invalid Frame Type"),
|
||||
CodecError::PayloadTooLarge => write!(f, "Payload too large"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CodecError {}
|
||||
36
src/serial_core/frame.rs
Normal file
36
src/serial_core/frame.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TactileAFrameMetaData {
|
||||
pub header: [u8; 2],
|
||||
pub payload_len: usize,
|
||||
pub device_addr: u8,
|
||||
pub extend_code: u8,
|
||||
pub func_code: u8,
|
||||
pub start_addr: u32,
|
||||
pub except_data_len: usize,
|
||||
pub checksum: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TactileAReqFrame {
|
||||
pub meta: TactileAFrameMetaData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TactileARepFrame {
|
||||
pub meta: TactileAFrameMetaData,
|
||||
pub status: TactileAFrameStatusCode,
|
||||
pub payload: Vec<u8>,
|
||||
pub dts_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TactileAFrameStatusCode {
|
||||
Success,
|
||||
Failure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TactileAFrame {
|
||||
Req(TactileAReqFrame),
|
||||
Rep(TactileARepFrame),
|
||||
}
|
||||
6
src/serial_core/mod.rs
Normal file
6
src/serial_core/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod codec;
|
||||
pub mod codecs;
|
||||
pub mod error;
|
||||
pub mod frame;
|
||||
pub mod serial;
|
||||
pub mod utils;
|
||||
96
src/serial_core/serial.rs
Normal file
96
src/serial_core/serial.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use crate::serial_core::codec::Codec;
|
||||
use crate::serial_core::codecs::tactile_a::TactileACodec;
|
||||
use crate::serial_core::frame::TactileAFrame;
|
||||
use crate::serial_core::utils::elapsed_millis;
|
||||
use crossbeam_channel::{Receiver, Sender, TryRecvError};
|
||||
use std::io::{Read, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const POLL_INTERVAL_MS: u64 = 10;
|
||||
|
||||
/// Runs the serial polling loop on the calling (background) thread.
|
||||
/// Sends decoded pressure matrix data (Vec<i32>) to the output channel.
|
||||
pub fn run_serial_loop(
|
||||
port: &mut dyn ReadWrite,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
cancel_rx: &Receiver<()>,
|
||||
sample_tx: &Sender<Vec<i32>>,
|
||||
) {
|
||||
let session_started_at = Instant::now();
|
||||
let mut codec = TactileACodec::new(cols, rows);
|
||||
let req_frame = TactileACodec::build_req_frame(cols, rows);
|
||||
let mut buffer = [0u8; 1024];
|
||||
let mut poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
|
||||
|
||||
loop {
|
||||
// Check cancel
|
||||
if cancel_rx.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Send poll request
|
||||
if let Ok(req_bytes) = codec.encode(&req_frame) {
|
||||
let _ = port.write_all(&req_bytes);
|
||||
}
|
||||
|
||||
// Read response with poll interval
|
||||
let deadline = Instant::now() + poll_interval;
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
match port.read(&mut buffer) {
|
||||
Ok(n) if n > 0 => {
|
||||
if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) {
|
||||
for frame in frames {
|
||||
if let TactileAFrame::Rep(rep) = frame {
|
||||
if let Ok(vals) = TactileACodec::parse_data_frame(&rep.payload) {
|
||||
let _ = sample_tx.try_send(vals);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[serial] read error: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait abstracting read+write for the serial port
|
||||
pub trait ReadWrite: Send {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
|
||||
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()>;
|
||||
}
|
||||
|
||||
/// Wrapper for serialport's Box<dyn SerialPort>
|
||||
pub struct SerialPortReadWrite {
|
||||
inner: Box<dyn serialport::SerialPort>,
|
||||
}
|
||||
|
||||
impl SerialPortReadWrite {
|
||||
pub fn new(port: Box<dyn serialport::SerialPort>) -> Self {
|
||||
Self { inner: port }
|
||||
}
|
||||
}
|
||||
|
||||
impl ReadWrite for SerialPortReadWrite {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.inner.read(buf)
|
||||
}
|
||||
|
||||
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
|
||||
self.inner.write_all(buf)
|
||||
}
|
||||
}
|
||||
10
src/serial_core/utils.rs
Normal file
10
src/serial_core/utils.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use std::time::Instant;
|
||||
|
||||
pub fn calc_crc8_itu(c: &[u8]) -> u8 {
|
||||
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
|
||||
crc8_itu_alg.checksum(c)
|
||||
}
|
||||
|
||||
pub fn elapsed_millis(start_at: Instant) -> u64 {
|
||||
start_at.elapsed().as_millis() as u64
|
||||
}
|
||||
49
src/theme.rs
49
src/theme.rs
@@ -84,34 +84,41 @@ pub fn apply_fonts(ctx: &egui::Context) {
|
||||
egui::FontData::from_static(include_bytes!("../static/Hack-Bold.ttf")).into(),
|
||||
);
|
||||
|
||||
if let Ok(font_data) = std::fs::read(r"C:\Windows\Fonts\msyh.ttc") {
|
||||
fonts.font_data.insert(
|
||||
"Microsoft-YaHei".to_owned(),
|
||||
egui::FontData::from_owned(font_data).into(),
|
||||
);
|
||||
let has_yahei = std::fs::read(r"C:\Windows\Fonts\msyh.ttc")
|
||||
.or_else(|_| std::fs::read(r"C:\Windows\Fonts\msyhbd.ttc"))
|
||||
.map(|font_data| {
|
||||
fonts.font_data.insert(
|
||||
"Microsoft-YaHei".to_owned(),
|
||||
egui::FontData::from_owned(font_data).into(),
|
||||
);
|
||||
})
|
||||
.is_ok();
|
||||
|
||||
fonts
|
||||
.families
|
||||
.entry(egui::FontFamily::Proportional)
|
||||
.or_default()
|
||||
.insert(0, "Hack-Bold".to_owned());
|
||||
if has_yahei {
|
||||
fonts
|
||||
.families
|
||||
.entry(egui::FontFamily::Proportional)
|
||||
.or_default()
|
||||
.push("Microsoft-YaHei".to_owned());
|
||||
}
|
||||
|
||||
fonts
|
||||
.families
|
||||
.entry(egui::FontFamily::Proportional)
|
||||
.or_default()
|
||||
.insert(0, "Hack-Bold".to_owned());
|
||||
fonts
|
||||
.families
|
||||
.entry(egui::FontFamily::Proportional)
|
||||
.or_default()
|
||||
.push("Microsoft-YaHei".to_owned());
|
||||
|
||||
fonts
|
||||
.families
|
||||
.entry(egui::FontFamily::Monospace)
|
||||
.or_default()
|
||||
.insert(0, "Hack-Bold".to_owned());
|
||||
fonts
|
||||
.families
|
||||
.entry(egui::FontFamily::Monospace)
|
||||
.or_default()
|
||||
.push("Microsoft-YaHei".to_owned());
|
||||
if has_yahei {
|
||||
fonts
|
||||
.families
|
||||
.entry(egui::FontFamily::Monospace)
|
||||
.or_default()
|
||||
.push("Microsoft-YaHei".to_owned());
|
||||
}
|
||||
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
115
src/ui.rs
115
src/ui.rs
@@ -1,6 +1,7 @@
|
||||
use eframe::egui;
|
||||
|
||||
use crate::{
|
||||
connection::{ConnectionManager, ConnectionState},
|
||||
theme::{ENGINEERING_DARK, accent_text, dim_text, group_frame, panel_frame, tag_button},
|
||||
utils::serial_enum,
|
||||
};
|
||||
@@ -29,6 +30,7 @@ pub struct ConfigPanelState {
|
||||
pub struct ConnectPanelState {
|
||||
pub mode: SerialMode,
|
||||
pub port: Vec<String>,
|
||||
pub selected_port: String,
|
||||
pub duration: u8,
|
||||
pub manual: bool,
|
||||
pub rows: u8,
|
||||
@@ -87,9 +89,11 @@ impl Default for ConfigPanelState {
|
||||
impl Default for ConnectPanelState {
|
||||
fn default() -> Self {
|
||||
let port = serial_enum().unwrap();
|
||||
let selected_port = port.first().cloned().unwrap_or_default();
|
||||
Self {
|
||||
mode: SerialMode::SingleModule,
|
||||
port,
|
||||
selected_port,
|
||||
duration: 10,
|
||||
manual: false,
|
||||
rows: 12,
|
||||
@@ -119,19 +123,18 @@ pub fn draw_connect_panel(
|
||||
ctx: &egui::Context,
|
||||
panel: &mut FloatingPanelState,
|
||||
config: &mut ConnectPanelState,
|
||||
connection: &ConnectionManager,
|
||||
) {
|
||||
let conn_state = connection.state();
|
||||
let is_connected = matches!(
|
||||
conn_state,
|
||||
ConnectionState::Connected | ConnectionState::Streaming
|
||||
);
|
||||
|
||||
draw_center_floating_panel(ctx, panel, "connect_center_panel", 64.0, |ui| {
|
||||
ui.set_min_width(320.0);
|
||||
|
||||
ui.vertical(|ui| {
|
||||
// ui.horizontal_centered(|ui| {
|
||||
// mode_button(ui, &mut config.mode, SerialMode::SingleModule, "单模块");
|
||||
// ui.add_space(8.0);
|
||||
// mode_button(ui, &mut config.mode, SerialMode::Manual, "全手");
|
||||
// ui.add_space(8.0);
|
||||
// mode_button(ui, &mut config.mode, SerialMode::Model, "模型");
|
||||
// });
|
||||
|
||||
let button_width = 96.0;
|
||||
let gap = 8.0;
|
||||
let total_width = button_width * 3.0 + gap * 2.0;
|
||||
@@ -161,18 +164,39 @@ pub fn draw_connect_panel(
|
||||
ui.colored_label(ENGINEERING_DARK.text, "串口");
|
||||
egui::ComboBox::from_id_salt("connect_ports")
|
||||
.width(130.0)
|
||||
.selected_text(
|
||||
config
|
||||
.port
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or("无可用串口"),
|
||||
)
|
||||
.selected_text(if config.selected_port.is_empty() {
|
||||
"无可用串口".to_owned()
|
||||
} else {
|
||||
config.selected_port.clone()
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
for port in &config.port {
|
||||
let _ = ui.selectable_label(false, port);
|
||||
let label = port.clone();
|
||||
ui.selectable_value(
|
||||
&mut config.selected_port,
|
||||
port.clone(),
|
||||
label,
|
||||
);
|
||||
}
|
||||
});
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new("⟳")
|
||||
.fill(ENGINEERING_DARK.panel_strong)
|
||||
.stroke(egui::Stroke::new(1.0, ENGINEERING_DARK.border_soft))
|
||||
.min_size(egui::vec2(24.0, 20.0)),
|
||||
)
|
||||
.on_hover_text("刷新串口")
|
||||
.clicked()
|
||||
{
|
||||
if let Ok(ports) = serial_enum() {
|
||||
if !ports.contains(&config.selected_port) {
|
||||
config.selected_port =
|
||||
ports.first().cloned().unwrap_or_default();
|
||||
}
|
||||
config.port = ports;
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.label("频率");
|
||||
@@ -203,6 +227,65 @@ pub fn draw_connect_panel(
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Connection status and button row
|
||||
ui.horizontal(|ui| {
|
||||
let status_text = match conn_state {
|
||||
ConnectionState::Disconnected => "未连接",
|
||||
ConnectionState::Connecting => "连接中...",
|
||||
ConnectionState::Connected => "已连接",
|
||||
ConnectionState::Streaming => "采集中",
|
||||
ConnectionState::Error => "连接错误",
|
||||
};
|
||||
let status_color = match conn_state {
|
||||
ConnectionState::Disconnected => ENGINEERING_DARK.text_dim,
|
||||
ConnectionState::Connecting => egui::Color32::from_rgb(200, 180, 60),
|
||||
ConnectionState::Connected => egui::Color32::from_rgb(158, 184, 101),
|
||||
ConnectionState::Streaming => egui::Color32::from_rgb(100, 200, 255),
|
||||
ConnectionState::Error => egui::Color32::from_rgb(255, 98, 82),
|
||||
};
|
||||
ui.colored_label(status_color, status_text);
|
||||
|
||||
let used = 100.0;
|
||||
let remaining = (ui.available_width() - used).max(0.0);
|
||||
ui.add_space(remaining);
|
||||
|
||||
let btn_label = if is_connected { "断开" } else { "连接" };
|
||||
let btn_fill = if is_connected {
|
||||
egui::Color32::from_rgb(180, 60, 60)
|
||||
} else {
|
||||
ENGINEERING_DARK.accent
|
||||
};
|
||||
let btn_stroke = if is_connected {
|
||||
egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 80, 80))
|
||||
} else {
|
||||
egui::Stroke::new(1.0, ENGINEERING_DARK.accent_hot)
|
||||
};
|
||||
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
egui::RichText::new(btn_label).color(egui::Color32::WHITE),
|
||||
)
|
||||
.fill(btn_fill)
|
||||
.stroke(btn_stroke)
|
||||
.min_size(egui::vec2(96.0, 28.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
if is_connected {
|
||||
connection.disconnect();
|
||||
} else if !config.selected_port.is_empty() {
|
||||
connection.connect(
|
||||
&config.selected_port,
|
||||
config.rows as u32,
|
||||
config.cols as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user