feat: Matrix test tool — live key press visualization in Tools tab
Sends MATRIX_TEST (0xB0) toggle, polls unsolicited KR events. 3-state colors: grey (untouched), green (pressed), purple (was activated). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
88ab57bebe
commit
9c6f069bf1
8 changed files with 285 additions and 3 deletions
|
|
@ -49,6 +49,9 @@ pub enum BgMsg {
|
|||
OtaDone(Result<(), String>),
|
||||
ConfigProgress(f32, String),
|
||||
ConfigDone(Result<String, String>),
|
||||
MatrixTestToggled(bool, u8, u8), // enabled, rows, cols
|
||||
MatrixTestEvent(u8, u8, u8), // row, col, state (1=pressed, 0=released)
|
||||
MatrixTestError(String),
|
||||
}
|
||||
|
||||
/// Spawn a background thread that locks the serial port and runs `f`.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use crate::{
|
|||
AdvancedBridge, AppState, BigramData, ComboData, ConnectionState, FingerLoadData,
|
||||
FlasherBridge, HandBalanceData, KeyOverrideData, KeymapBridge, LayoutBridge, LeaderData,
|
||||
MacroBridge, MacroData, MainWindow, RowUsageData, SettingsBridge, StatsBridge,
|
||||
TapDanceAction, TapDanceData, TopKeyData, LayerInfo,
|
||||
TapDanceAction, TapDanceData, ToolsBridge, TopKeyData, LayerInfo,
|
||||
};
|
||||
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
|
||||
use std::rc::Rc;
|
||||
|
|
@ -340,6 +340,59 @@ fn handle_msg(
|
|||
Err(e) => { s.set_config_progress(0.0); s.set_config_status(SharedString::from(format!("Error: {}", e))); }
|
||||
}
|
||||
}
|
||||
BgMsg::MatrixTestToggled(enabled, _rows, _cols) => {
|
||||
let tb = window.global::<ToolsBridge>();
|
||||
tb.set_matrix_test_active(enabled);
|
||||
if enabled {
|
||||
// Build matrix keycap model from current keys
|
||||
let keys = keys_arc.borrow();
|
||||
let model = models::build_keycap_model(&keys);
|
||||
// Reset all colors to default (grey)
|
||||
for i in 0..model.row_count() {
|
||||
let mut item = model.row_data(i).unwrap();
|
||||
item.color = slint::Color::from_argb_u8(255, 0x44, 0x47, 0x5a);
|
||||
item.label = SharedString::from(format!("R{}C{}", keys[i].row, keys[i].col));
|
||||
item.heat = 0.0;
|
||||
model.set_row_data(i, item);
|
||||
}
|
||||
let mut max_x: f32 = 0.0;
|
||||
let mut max_y: f32 = 0.0;
|
||||
for kp in keys.iter() {
|
||||
if kp.x + kp.w > max_x { max_x = kp.x + kp.w; }
|
||||
if kp.y + kp.h > max_y { max_y = kp.y + kp.h; }
|
||||
}
|
||||
tb.set_matrix_content_width(max_x);
|
||||
tb.set_matrix_content_height(max_y);
|
||||
tb.set_matrix_keycaps(ModelRc::from(model));
|
||||
tb.set_matrix_test_status(SharedString::from("Matrix test active — press keys"));
|
||||
} else {
|
||||
tb.set_matrix_test_status(SharedString::from("Matrix test stopped"));
|
||||
}
|
||||
}
|
||||
BgMsg::MatrixTestEvent(row, col, state) => {
|
||||
let tb = window.global::<ToolsBridge>();
|
||||
let keycaps = tb.get_matrix_keycaps();
|
||||
let keys = keys_arc.borrow();
|
||||
// Find the keycap matching this row/col
|
||||
for i in 0..keycaps.row_count() {
|
||||
if i >= keys.len() { break; }
|
||||
if keys[i].row == row as usize && keys[i].col == col as usize {
|
||||
let mut item = keycaps.row_data(i).unwrap();
|
||||
if state == 1 {
|
||||
item.color = slint::Color::from_argb_u8(255, 0x50, 0xFA, 0x7B); // green = pressed
|
||||
} else {
|
||||
item.color = slint::Color::from_argb_u8(255, 0xBD, 0x93, 0xF9); // purple = was activated
|
||||
}
|
||||
keycaps.set_row_data(i, item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
BgMsg::MatrixTestError(e) => {
|
||||
let tb = window.global::<ToolsBridge>();
|
||||
tb.set_matrix_test_active(false);
|
||||
tb.set_matrix_test_status(SharedString::from(format!("Error: {}", e)));
|
||||
}
|
||||
BgMsg::MacroList(macros) => {
|
||||
let model: Vec<MacroData> = macros.iter().map(|m| {
|
||||
let steps_str: Vec<String> = m.steps.iter().map(|s| {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ mod flasher;
|
|||
mod layout;
|
||||
mod connection;
|
||||
mod key_selector;
|
||||
mod tools;
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ fn main() {
|
|||
settings::setup(&window, &ctx);
|
||||
flasher::setup(&window, &ctx);
|
||||
layout::setup(&window, &ctx);
|
||||
tools::setup(&window, &ctx);
|
||||
|
||||
dispatch::run(&window, &ctx, bg_rx);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ pub mod cmd {
|
|||
pub const TAMA_MEDICINE: u8 = 0xA6;
|
||||
pub const TAMA_SAVE: u8 = 0xA7;
|
||||
|
||||
// Diagnostics
|
||||
pub const MATRIX_TEST: u8 = 0xB0;
|
||||
|
||||
// OTA
|
||||
pub const OTA_START: u8 = 0xF0;
|
||||
pub const OTA_DATA: u8 = 0xF1;
|
||||
|
|
@ -285,3 +288,21 @@ pub fn parse_kr(data: &[u8]) -> Result<(KrResponse, usize), String> {
|
|||
let consumed = payload_end + 1 - pos;
|
||||
Ok((KrResponse { cmd, status, payload }, consumed))
|
||||
}
|
||||
|
||||
/// Parse all KR frames from a byte buffer (for unsolicited events).
|
||||
pub fn parse_all_kr(data: &[u8]) -> Vec<KrResponse> {
|
||||
let mut results = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < data.len() {
|
||||
match parse_kr(&data[offset..]) {
|
||||
Ok((resp, consumed)) => {
|
||||
offset += consumed;
|
||||
results.push(resp);
|
||||
}
|
||||
Err(_) => {
|
||||
offset += 1; // skip garbage byte
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
|
|
|||
101
src/tools.rs
Normal file
101
src/tools.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
use crate::context::{AppContext, BgMsg};
|
||||
use crate::protocol::binary::{self as bp};
|
||||
use crate::{MainWindow, ToolsBridge};
|
||||
use slint::ComponentHandle;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Shared flag to stop the matrix test polling thread.
|
||||
static MATRIX_POLLING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
setup_toggle_matrix_test(window, ctx);
|
||||
}
|
||||
|
||||
fn setup_toggle_matrix_test(window: &MainWindow, ctx: &AppContext) {
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<ToolsBridge>().on_toggle_matrix_test(move || {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// Send toggle command
|
||||
match ser.send_binary(bp::cmd::MATRIX_TEST, &[]) {
|
||||
Ok(resp) => {
|
||||
if resp.payload.len() >= 3 {
|
||||
let enabled = resp.payload[0];
|
||||
let rows = resp.payload[1];
|
||||
let cols = resp.payload[2];
|
||||
let _ = tx.send(BgMsg::MatrixTestToggled(enabled != 0, rows, cols));
|
||||
|
||||
if enabled != 0 {
|
||||
// Start polling thread for unsolicited events
|
||||
MATRIX_POLLING.store(true, Ordering::SeqCst);
|
||||
let serial2 = serial.clone();
|
||||
let tx2 = tx.clone();
|
||||
drop(ser); // release lock before spawning poller
|
||||
std::thread::spawn(move || {
|
||||
poll_matrix_events(serial2, tx2);
|
||||
});
|
||||
} else {
|
||||
MATRIX_POLLING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(BgMsg::MatrixTestError(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Poll serial for unsolicited KR [0xB0] events.
|
||||
fn poll_matrix_events(
|
||||
serial: Arc<std::sync::Mutex<crate::protocol::serial::SerialManager>>,
|
||||
tx: std::sync::mpsc::Sender<BgMsg>,
|
||||
) {
|
||||
let mut buf = vec![0u8; 256];
|
||||
|
||||
while MATRIX_POLLING.load(Ordering::SeqCst) {
|
||||
let read_result = {
|
||||
let mut ser = match serial.try_lock() {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let port = match ser.port_mut() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
MATRIX_POLLING.store(false, Ordering::SeqCst);
|
||||
let _ = tx.send(BgMsg::MatrixTestError("Port disconnected".into()));
|
||||
break;
|
||||
}
|
||||
};
|
||||
port.read(&mut buf)
|
||||
};
|
||||
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
let frames = bp::parse_all_kr(&buf[..n]);
|
||||
for frame in frames {
|
||||
if frame.cmd == bp::cmd::MATRIX_TEST && frame.is_ok() && frame.payload.len() >= 3 {
|
||||
let row = frame.payload[0];
|
||||
let col = frame.payload[1];
|
||||
let state = frame.payload[2];
|
||||
let _ = tx.send(BgMsg::MatrixTestEvent(row, col, state));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -289,6 +289,18 @@ export global LayoutBridge {
|
|||
callback export-json();
|
||||
}
|
||||
|
||||
// ---- Matrix Test ----
|
||||
|
||||
export global ToolsBridge {
|
||||
in-out property <bool> matrix-test-active: false;
|
||||
in property <string> matrix-test-status: "";
|
||||
// Keycap model with live press state (reuses KeycapData, color = press state)
|
||||
in property <[KeycapData]> matrix-keycaps;
|
||||
in property <length> matrix-content-width: 860px;
|
||||
in property <length> matrix-content-height: 360px;
|
||||
callback toggle-matrix-test();
|
||||
}
|
||||
|
||||
// ---- Key Selector ----
|
||||
|
||||
export struct KeyEntry {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { TabSettings } from "tabs/tab_settings.slint";
|
|||
import { TabTools } from "tabs/tab_tools.slint";
|
||||
|
||||
export { AppState, Theme }
|
||||
export { ConnectionBridge, KeymapBridge, SettingsBridge, StatsBridge, AdvancedBridge, MacroBridge, FlasherBridge, KeySelectorBridge, LayoutBridge } from "globals.slint";
|
||||
export { ConnectionBridge, KeymapBridge, SettingsBridge, StatsBridge, AdvancedBridge, MacroBridge, FlasherBridge, KeySelectorBridge, LayoutBridge, ToolsBridge } from "globals.slint";
|
||||
|
||||
component DarkTab inherits Rectangle {
|
||||
in property <string> title;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Theme } from "../theme.slint";
|
|||
import { DarkButton } from "../components/dark_button.slint";
|
||||
import { DarkCheckbox } from "../components/dark_checkbox.slint";
|
||||
import { DarkComboBox } from "../components/dark_combo_box.slint";
|
||||
import { FlasherBridge, LayoutBridge, KeycapData, AppState, ConnectionState } from "../globals.slint";
|
||||
import { FlasherBridge, LayoutBridge, ToolsBridge, KeycapData, AppState, ConnectionState } from "../globals.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
|
||||
export component TabTools inherits Rectangle {
|
||||
|
|
@ -158,6 +158,96 @@ export component TabTools inherits Rectangle {
|
|||
}
|
||||
}
|
||||
|
||||
// ===================== MATRIX TEST =====================
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 10px;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
|
||||
Text {
|
||||
text: "Matrix Test";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
DarkButton {
|
||||
text: ToolsBridge.matrix-test-active ? "Stop Test" : "Start Test";
|
||||
primary: !ToolsBridge.matrix-test-active;
|
||||
enabled: AppState.connection == ConnectionState.connected;
|
||||
clicked => { ToolsBridge.toggle-matrix-test(); }
|
||||
}
|
||||
}
|
||||
|
||||
if ToolsBridge.matrix-test-status != "" : Text {
|
||||
text: ToolsBridge.matrix-test-status;
|
||||
color: ToolsBridge.matrix-test-active ? Theme.accent-green : Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
// Keyboard render for matrix test
|
||||
matrix-area := Rectangle {
|
||||
height: 260px;
|
||||
background: Theme.bg-surface;
|
||||
border-radius: 8px;
|
||||
clip: true;
|
||||
|
||||
property <float> scale-x: self.width / ToolsBridge.matrix-content-width;
|
||||
property <float> scale-y: self.height / ToolsBridge.matrix-content-height;
|
||||
property <float> scale: min(scale-x, scale-y) * 0.95;
|
||||
property <length> offset-x: (self.width - ToolsBridge.matrix-content-width * scale) / 2;
|
||||
property <length> offset-y: (self.height - ToolsBridge.matrix-content-height * scale) / 2;
|
||||
|
||||
if ToolsBridge.matrix-keycaps.length == 0 : Text {
|
||||
text: "Press \"Start Test\" to begin";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
for keycap[idx] in ToolsBridge.matrix-keycaps : Rectangle {
|
||||
x: matrix-area.offset-x + keycap.x * matrix-area.scale;
|
||||
y: matrix-area.offset-y + keycap.y * matrix-area.scale;
|
||||
width: keycap.w * matrix-area.scale;
|
||||
height: keycap.h * matrix-area.scale;
|
||||
background: transparent;
|
||||
|
||||
Rectangle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: keycap.color;
|
||||
border-width: 1px;
|
||||
border-color: Theme.bg-primary;
|
||||
transform-rotation: keycap.rotation * 1deg;
|
||||
transform-origin: {
|
||||
x: self.width / 2,
|
||||
y: self.height / 2,
|
||||
};
|
||||
|
||||
Text {
|
||||
text: keycap.label;
|
||||
color: Theme.fg-primary;
|
||||
font-size: max(7px, 10px * matrix-area.scale);
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== ESP32 FLASHER =====================
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
|
|
|
|||
Loading…
Reference in a new issue