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>),
|
OtaDone(Result<(), String>),
|
||||||
ConfigProgress(f32, String),
|
ConfigProgress(f32, String),
|
||||||
ConfigDone(Result<String, 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`.
|
/// Spawn a background thread that locks the serial port and runs `f`.
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use crate::{
|
||||||
AdvancedBridge, AppState, BigramData, ComboData, ConnectionState, FingerLoadData,
|
AdvancedBridge, AppState, BigramData, ComboData, ConnectionState, FingerLoadData,
|
||||||
FlasherBridge, HandBalanceData, KeyOverrideData, KeymapBridge, LayoutBridge, LeaderData,
|
FlasherBridge, HandBalanceData, KeyOverrideData, KeymapBridge, LayoutBridge, LeaderData,
|
||||||
MacroBridge, MacroData, MainWindow, RowUsageData, SettingsBridge, StatsBridge,
|
MacroBridge, MacroData, MainWindow, RowUsageData, SettingsBridge, StatsBridge,
|
||||||
TapDanceAction, TapDanceData, TopKeyData, LayerInfo,
|
TapDanceAction, TapDanceData, ToolsBridge, TopKeyData, LayerInfo,
|
||||||
};
|
};
|
||||||
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
|
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
|
||||||
use std::rc::Rc;
|
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))); }
|
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) => {
|
BgMsg::MacroList(macros) => {
|
||||||
let model: Vec<MacroData> = macros.iter().map(|m| {
|
let model: Vec<MacroData> = macros.iter().map(|m| {
|
||||||
let steps_str: Vec<String> = m.steps.iter().map(|s| {
|
let steps_str: Vec<String> = m.steps.iter().map(|s| {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ mod flasher;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod connection;
|
mod connection;
|
||||||
mod key_selector;
|
mod key_selector;
|
||||||
|
mod tools;
|
||||||
|
|
||||||
slint::include_modules!();
|
slint::include_modules!();
|
||||||
|
|
||||||
|
|
@ -53,6 +54,7 @@ fn main() {
|
||||||
settings::setup(&window, &ctx);
|
settings::setup(&window, &ctx);
|
||||||
flasher::setup(&window, &ctx);
|
flasher::setup(&window, &ctx);
|
||||||
layout::setup(&window, &ctx);
|
layout::setup(&window, &ctx);
|
||||||
|
tools::setup(&window, &ctx);
|
||||||
|
|
||||||
dispatch::run(&window, &ctx, bg_rx);
|
dispatch::run(&window, &ctx, bg_rx);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ pub mod cmd {
|
||||||
pub const TAMA_MEDICINE: u8 = 0xA6;
|
pub const TAMA_MEDICINE: u8 = 0xA6;
|
||||||
pub const TAMA_SAVE: u8 = 0xA7;
|
pub const TAMA_SAVE: u8 = 0xA7;
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
pub const MATRIX_TEST: u8 = 0xB0;
|
||||||
|
|
||||||
// OTA
|
// OTA
|
||||||
pub const OTA_START: u8 = 0xF0;
|
pub const OTA_START: u8 = 0xF0;
|
||||||
pub const OTA_DATA: u8 = 0xF1;
|
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;
|
let consumed = payload_end + 1 - pos;
|
||||||
Ok((KrResponse { cmd, status, payload }, consumed))
|
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();
|
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 ----
|
// ---- Key Selector ----
|
||||||
|
|
||||||
export struct KeyEntry {
|
export struct KeyEntry {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { TabSettings } from "tabs/tab_settings.slint";
|
||||||
import { TabTools } from "tabs/tab_tools.slint";
|
import { TabTools } from "tabs/tab_tools.slint";
|
||||||
|
|
||||||
export { AppState, Theme }
|
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 {
|
component DarkTab inherits Rectangle {
|
||||||
in property <string> title;
|
in property <string> title;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Theme } from "../theme.slint";
|
||||||
import { DarkButton } from "../components/dark_button.slint";
|
import { DarkButton } from "../components/dark_button.slint";
|
||||||
import { DarkCheckbox } from "../components/dark_checkbox.slint";
|
import { DarkCheckbox } from "../components/dark_checkbox.slint";
|
||||||
import { DarkComboBox } from "../components/dark_combo_box.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";
|
import { ScrollView } from "std-widgets.slint";
|
||||||
|
|
||||||
export component TabTools inherits Rectangle {
|
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 =====================
|
// ===================== ESP32 FLASHER =====================
|
||||||
Rectangle {
|
Rectangle {
|
||||||
background: Theme.bg-secondary;
|
background: Theme.bg-secondary;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue