diff --git a/src/context.rs b/src/context.rs index 8d8292c..25b9856 100644 --- a/src/context.rs +++ b/src/context.rs @@ -49,6 +49,9 @@ pub enum BgMsg { OtaDone(Result<(), String>), ConfigProgress(f32, String), ConfigDone(Result), + 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`. diff --git a/src/dispatch.rs b/src/dispatch.rs index 384567c..4e5f51f 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -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::(); + 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::(); + 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::(); + tb.set_matrix_test_active(false); + tb.set_matrix_test_status(SharedString::from(format!("Error: {}", e))); + } BgMsg::MacroList(macros) => { let model: Vec = macros.iter().map(|m| { let steps_str: Vec = m.steps.iter().map(|s| { diff --git a/src/main.rs b/src/main.rs index 437727a..e7383de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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); } diff --git a/src/protocol/binary.rs b/src/protocol/binary.rs index bf2c741..eb014b9 100644 --- a/src/protocol/binary.rs +++ b/src/protocol/binary.rs @@ -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 { + 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 +} diff --git a/src/tools.rs b/src/tools.rs new file mode 100644 index 0000000..cb6366f --- /dev/null +++ b/src/tools.rs @@ -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::().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>, + tx: std::sync::mpsc::Sender, +) { + 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)); + } + } + } +} diff --git a/ui/globals.slint b/ui/globals.slint index 591f0f7..9fa96c3 100644 --- a/ui/globals.slint +++ b/ui/globals.slint @@ -289,6 +289,18 @@ export global LayoutBridge { callback export-json(); } +// ---- Matrix Test ---- + +export global ToolsBridge { + in-out property matrix-test-active: false; + in property matrix-test-status: ""; + // Keycap model with live press state (reuses KeycapData, color = press state) + in property <[KeycapData]> matrix-keycaps; + in property matrix-content-width: 860px; + in property matrix-content-height: 360px; + callback toggle-matrix-test(); +} + // ---- Key Selector ---- export struct KeyEntry { diff --git a/ui/main.slint b/ui/main.slint index b851c15..c586e1a 100644 --- a/ui/main.slint +++ b/ui/main.slint @@ -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 title; diff --git a/ui/tabs/tab_tools.slint b/ui/tabs/tab_tools.slint index 106094a..29f4a98 100644 --- a/ui/tabs/tab_tools.slint +++ b/ui/tabs/tab_tools.slint @@ -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 scale-x: self.width / ToolsBridge.matrix-content-width; + property scale-y: self.height / ToolsBridge.matrix-content-height; + property scale: min(scale-x, scale-y) * 0.95; + property offset-x: (self.width - ToolsBridge.matrix-content-width * scale) / 2; + property 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;