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:
Mae PUGIN 2026-04-10 10:05:36 +02:00
parent 88ab57bebe
commit 9c6f069bf1
8 changed files with 285 additions and 3 deletions

View file

@ -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`.

View file

@ -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| {

View file

@ -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);
}

View file

@ -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
View 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));
}
}
}
}

View file

@ -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 {

View file

@ -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;

View file

@ -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;