feat: Complete KeSp Controller — Slint UI port
Full port of the KaSe/KeSp split keyboard configurator from egui to Slint: - 6 tabs: Keymap, Advanced, Macros, Stats, Settings, Flash - Responsive keyboard view with scale-to-fit and key rotations - Key selector popup with categorized grid, MT/LT builders, hex input - Combo key picker with inline keyboard visual - Macro step builder with visual tags - Serial communication via background threads + mpsc polling - Heatmap overlay with blue-yellow-red gradient - OTA flasher with prog port VID filtering and partition selector - WPM polling, Tamagotchi, Autoshift controls - Dracula theme matching egui version Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
32ee3a6d26
47 changed files with 18999 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
6511
Cargo.lock
generated
Normal file
6511
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "kase-controller"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
slint = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serialport = "4"
|
||||
rfd = "0.15"
|
||||
|
||||
[build-dependencies]
|
||||
slint-build = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
3
build.rs
Normal file
3
build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
slint_build::compile("ui/main.slint").unwrap();
|
||||
}
|
||||
260
default.json
Normal file
260
default.json
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
{
|
||||
"Group": {
|
||||
"Children": [
|
||||
{
|
||||
"Line": {
|
||||
"Orientation": "Horizontal",
|
||||
"Children": [
|
||||
{
|
||||
"Group": {
|
||||
"Children": [
|
||||
{
|
||||
"Line": {
|
||||
"Orientation": "Horizontal",
|
||||
"Children": [
|
||||
{
|
||||
"Line": {
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 0, "Row": 4 } },
|
||||
{ "Keycap": { "Column": 0, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 0, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 0, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 0, "Row": 3 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 1, "Row": 4 } },
|
||||
{ "Keycap": { "Column": 1, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 1, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 1, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 1, "Row": 3 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Margin": "8,25,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": 5 } },
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 2, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 2, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 2, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 2, "Row": 3 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Margin": "10,5,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": 10 } },
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 3, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 3, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 3, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 3, "Row": 3 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Margin": "0,25,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": 10 } },
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 4, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 4, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 4, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 4, "Row": 3 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Margin": "0,38,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": 10 } },
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 5, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 5, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 5, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 5, "Row": 3 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Keycap": {
|
||||
"Column": 6,
|
||||
"Row": 0,
|
||||
"Margin": "-8,170,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": 10 } }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{ "Keycap": { "Column": 2, "Row": 4, "Width": 60, "Margin": "110,228,0,0" } },
|
||||
{
|
||||
"Keycap": {
|
||||
"Column": 3,
|
||||
"Row": 4,
|
||||
"Margin": "174,225,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": 10 } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"Keycap": {
|
||||
"Column": 4,
|
||||
"Row": 4,
|
||||
"Margin": "228,240,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": 20 } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"Keycap": {
|
||||
"Column": 5,
|
||||
"Row": 4,
|
||||
"Margin": "280,270,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": 40 } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"Keycap": {
|
||||
"Column": 6,
|
||||
"Row": 4,
|
||||
"Margin": "320,305,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": 40 } }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Group": {
|
||||
"Margin": "420,0,0,0",
|
||||
"HorizontalAlignment": "Right",
|
||||
"Children": [
|
||||
{
|
||||
"Line": {
|
||||
"Orientation": "Horizontal",
|
||||
"Margin": "0,290,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": -40 } },
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 6, "Row": 3 } },
|
||||
{ "Keycap": { "Column": 7, "Row": 4 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Keycap": {
|
||||
"Column": 8,
|
||||
"Row": 4,
|
||||
"Margin": "95,245,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": -20 } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"Keycap": {
|
||||
"Column": 9,
|
||||
"Row": 4,
|
||||
"Margin": "145,230,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": -10 } }
|
||||
}
|
||||
},
|
||||
{ "Keycap": { "Column": 10, "Row": 4, "Width": 60, "Margin": "200,228,0,0" } },
|
||||
{
|
||||
"Line": {
|
||||
"Orientation": "Horizontal",
|
||||
"Children": [
|
||||
{
|
||||
"Keycap": {
|
||||
"Column": 6,
|
||||
"Row": 2,
|
||||
"Margin": "0,170,-8,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": -10 } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Margin": "0,38,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": -10 } },
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 7, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 7, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 7, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 7, "Row": 3 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Margin": "0,25,0,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": -10 } },
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 8, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 8, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 8, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 8, "Row": 3 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Margin": "0,5,10,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": -10 } },
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 9, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 9, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 9, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 9, "Row": 3 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Margin": "0,25,8,0",
|
||||
"RenderTransform": { "RotateTransform": { "Angle": -5 } },
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 10, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 10, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 10, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 10, "Row": 3 } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 11, "Row": 4 } },
|
||||
{ "Keycap": { "Column": 11, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 11, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 11, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 11, "Row": 3 } }
|
||||
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"Line": {
|
||||
"Children": [
|
||||
{ "Keycap": { "Column": 12, "Row": 4 } },
|
||||
{ "Keycap": { "Column": 12, "Row": 0 } },
|
||||
{ "Keycap": { "Column": 12, "Row": 1 } },
|
||||
{ "Keycap": { "Column": 12, "Row": 2 } },
|
||||
{ "Keycap": { "Column": 12, "Row": 3 } }
|
||||
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
259
original-src/binary_protocol.rs
Normal file
259
original-src/binary_protocol.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/// KaSe Binary CDC Protocol v2
|
||||
/// Frame: KS(2) + cmd(1) + len(2 LE) + payload(N) + crc8(1)
|
||||
/// Response: KR(2) + cmd(1) + status(1) + len(2 LE) + payload(N) + crc8(1)
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub mod cmd {
|
||||
// System
|
||||
pub const VERSION: u8 = 0x01;
|
||||
pub const FEATURES: u8 = 0x02;
|
||||
pub const DFU: u8 = 0x03;
|
||||
pub const PING: u8 = 0x04;
|
||||
|
||||
// Keymap
|
||||
pub const SETLAYER: u8 = 0x10;
|
||||
pub const SETKEY: u8 = 0x11;
|
||||
pub const KEYMAP_CURRENT: u8 = 0x12;
|
||||
pub const KEYMAP_GET: u8 = 0x13;
|
||||
pub const LAYER_INDEX: u8 = 0x14;
|
||||
pub const LAYER_NAME: u8 = 0x15;
|
||||
|
||||
// Layout
|
||||
pub const SET_LAYOUT_NAME: u8 = 0x20;
|
||||
pub const LIST_LAYOUTS: u8 = 0x21;
|
||||
pub const GET_LAYOUT_JSON: u8 = 0x22;
|
||||
|
||||
// Macros
|
||||
pub const LIST_MACROS: u8 = 0x30;
|
||||
pub const MACRO_ADD: u8 = 0x31;
|
||||
pub const MACRO_ADD_SEQ: u8 = 0x32;
|
||||
pub const MACRO_DELETE: u8 = 0x33;
|
||||
|
||||
// Statistics
|
||||
pub const KEYSTATS_BIN: u8 = 0x40;
|
||||
pub const KEYSTATS_RESET: u8 = 0x42;
|
||||
pub const BIGRAMS_BIN: u8 = 0x43;
|
||||
pub const BIGRAMS_RESET: u8 = 0x45;
|
||||
|
||||
// Tap Dance
|
||||
pub const TD_SET: u8 = 0x50;
|
||||
pub const TD_LIST: u8 = 0x51;
|
||||
pub const TD_DELETE: u8 = 0x52;
|
||||
|
||||
// Combos
|
||||
pub const COMBO_SET: u8 = 0x60;
|
||||
pub const COMBO_LIST: u8 = 0x61;
|
||||
pub const COMBO_DELETE: u8 = 0x62;
|
||||
|
||||
// Leader
|
||||
pub const LEADER_SET: u8 = 0x70;
|
||||
pub const LEADER_LIST: u8 = 0x71;
|
||||
pub const LEADER_DELETE: u8 = 0x72;
|
||||
|
||||
// Bluetooth
|
||||
pub const BT_QUERY: u8 = 0x80;
|
||||
pub const BT_SWITCH: u8 = 0x81;
|
||||
pub const BT_PAIR: u8 = 0x82;
|
||||
pub const BT_DISCONNECT: u8 = 0x83;
|
||||
pub const BT_NEXT: u8 = 0x84;
|
||||
pub const BT_PREV: u8 = 0x85;
|
||||
|
||||
// Features
|
||||
pub const AUTOSHIFT_TOGGLE: u8 = 0x90;
|
||||
pub const KO_SET: u8 = 0x91;
|
||||
pub const KO_LIST: u8 = 0x92;
|
||||
pub const KO_DELETE: u8 = 0x93;
|
||||
pub const WPM_QUERY: u8 = 0x94;
|
||||
pub const TRILAYER_SET: u8 = 0x94;
|
||||
|
||||
// Tamagotchi
|
||||
pub const TAMA_QUERY: u8 = 0xA0;
|
||||
pub const TAMA_ENABLE: u8 = 0xA1;
|
||||
pub const TAMA_DISABLE: u8 = 0xA2;
|
||||
pub const TAMA_FEED: u8 = 0xA3;
|
||||
pub const TAMA_PLAY: u8 = 0xA4;
|
||||
pub const TAMA_SLEEP: u8 = 0xA5;
|
||||
pub const TAMA_MEDICINE: u8 = 0xA6;
|
||||
pub const TAMA_SAVE: u8 = 0xA7;
|
||||
|
||||
// OTA
|
||||
pub const OTA_START: u8 = 0xF0;
|
||||
pub const OTA_DATA: u8 = 0xF1;
|
||||
pub const OTA_ABORT: u8 = 0xF2;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub mod status {
|
||||
pub const OK: u8 = 0x00;
|
||||
pub const ERR_UNKNOWN: u8 = 0x01;
|
||||
pub const ERR_CRC: u8 = 0x02;
|
||||
pub const ERR_INVALID: u8 = 0x03;
|
||||
pub const ERR_RANGE: u8 = 0x04;
|
||||
pub const ERR_BUSY: u8 = 0x05;
|
||||
pub const ERR_OVERFLOW: u8 = 0x06;
|
||||
}
|
||||
|
||||
/// CRC-8/MAXIM (polynomial 0x31, init 0x00)
|
||||
pub fn crc8(data: &[u8]) -> u8 {
|
||||
let mut crc: u8 = 0x00;
|
||||
for &b in data {
|
||||
crc ^= b;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x80 != 0 {
|
||||
(crc << 1) ^ 0x31
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Build a KS request frame.
|
||||
pub fn ks_frame(cmd_id: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let len = payload.len() as u16;
|
||||
let mut frame = Vec::with_capacity(6 + payload.len());
|
||||
frame.push(0x4B); // 'K'
|
||||
frame.push(0x53); // 'S'
|
||||
frame.push(cmd_id);
|
||||
frame.push((len & 0xFF) as u8);
|
||||
frame.push((len >> 8) as u8);
|
||||
frame.extend_from_slice(payload);
|
||||
frame.push(crc8(payload));
|
||||
frame
|
||||
}
|
||||
|
||||
/// Build MACRO_ADD_SEQ payload: [slot][name_len][name...][step_count][{kc,mod}...]
|
||||
pub fn macro_add_seq_payload(slot: u8, name: &str, steps_hex: &str) -> Vec<u8> {
|
||||
let name_bytes = name.as_bytes();
|
||||
let name_len = name_bytes.len().min(255) as u8;
|
||||
|
||||
// Parse hex steps "06:01,FF:0A,19:01" into (kc, mod) pairs
|
||||
let mut step_pairs: Vec<(u8, u8)> = Vec::new();
|
||||
if !steps_hex.is_empty() {
|
||||
for part in steps_hex.split(',') {
|
||||
let trimmed = part.trim();
|
||||
let kv: Vec<&str> = trimmed.split(':').collect();
|
||||
let has_two = kv.len() == 2;
|
||||
if !has_two {
|
||||
continue;
|
||||
}
|
||||
let kc = u8::from_str_radix(kv[0].trim(), 16).unwrap_or(0);
|
||||
let md = u8::from_str_radix(kv[1].trim(), 16).unwrap_or(0);
|
||||
step_pairs.push((kc, md));
|
||||
}
|
||||
}
|
||||
let step_count = step_pairs.len().min(255) as u8;
|
||||
|
||||
let mut payload = Vec::new();
|
||||
payload.push(slot);
|
||||
payload.push(name_len);
|
||||
payload.extend_from_slice(&name_bytes[..name_len as usize]);
|
||||
payload.push(step_count);
|
||||
for (kc, md) in &step_pairs {
|
||||
payload.push(*kc);
|
||||
payload.push(*md);
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
/// Build MACRO_DELETE payload: [slot]
|
||||
pub fn macro_delete_payload(slot: u8) -> Vec<u8> {
|
||||
vec![slot]
|
||||
}
|
||||
|
||||
/// Build COMBO_SET payload: [index][r1][c1][r2][c2][result]
|
||||
pub fn combo_set_payload(index: u8, r1: u8, c1: u8, r2: u8, c2: u8, result: u8) -> Vec<u8> {
|
||||
vec![index, r1, c1, r2, c2, result]
|
||||
}
|
||||
|
||||
/// Build TD_SET payload: [index][a1][a2][a3][a4]
|
||||
pub fn td_set_payload(index: u8, actions: &[u16; 4]) -> Vec<u8> {
|
||||
vec![index, actions[0] as u8, actions[1] as u8, actions[2] as u8, actions[3] as u8]
|
||||
}
|
||||
|
||||
/// Build KO_SET payload: [index][trigger_key][trigger_mod][result_key][result_mod]
|
||||
pub fn ko_set_payload(index: u8, trig_key: u8, trig_mod: u8, res_key: u8, res_mod: u8) -> Vec<u8> {
|
||||
vec![index, trig_key, trig_mod, res_key, res_mod]
|
||||
}
|
||||
|
||||
/// Build LEADER_SET payload: [index][seq_len][seq...][result][result_mod]
|
||||
pub fn leader_set_payload(index: u8, sequence: &[u8], result: u8, result_mod: u8) -> Vec<u8> {
|
||||
let seq_len = sequence.len().min(4) as u8;
|
||||
let mut payload = Vec::with_capacity(4 + sequence.len());
|
||||
payload.push(index);
|
||||
payload.push(seq_len);
|
||||
payload.extend_from_slice(&sequence[..seq_len as usize]);
|
||||
payload.push(result);
|
||||
payload.push(result_mod);
|
||||
payload
|
||||
}
|
||||
|
||||
/// Parsed KR response.
|
||||
#[derive(Debug)]
|
||||
pub struct KrResponse {
|
||||
#[allow(dead_code)]
|
||||
pub cmd: u8,
|
||||
pub status: u8,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl KrResponse {
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.status == status::OK
|
||||
}
|
||||
|
||||
pub fn status_name(&self) -> &str {
|
||||
match self.status {
|
||||
status::OK => "OK",
|
||||
status::ERR_UNKNOWN => "ERR_UNKNOWN",
|
||||
status::ERR_CRC => "ERR_CRC",
|
||||
status::ERR_INVALID => "ERR_INVALID",
|
||||
status::ERR_RANGE => "ERR_RANGE",
|
||||
status::ERR_BUSY => "ERR_BUSY",
|
||||
status::ERR_OVERFLOW => "ERR_OVERFLOW",
|
||||
_ => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a KR response from raw bytes. Returns (response, bytes_consumed).
|
||||
pub fn parse_kr(data: &[u8]) -> Result<(KrResponse, usize), String> {
|
||||
// Find KR magic
|
||||
let pos = data
|
||||
.windows(2)
|
||||
.position(|w| w[0] == 0x4B && w[1] == 0x52)
|
||||
.ok_or("No KR header found")?;
|
||||
|
||||
if data.len() < pos + 7 {
|
||||
return Err("Response too short".into());
|
||||
}
|
||||
|
||||
let cmd = data[pos + 2];
|
||||
let status = data[pos + 3];
|
||||
let plen = data[pos + 4] as u16 | ((data[pos + 5] as u16) << 8);
|
||||
let payload_start = pos + 6;
|
||||
let payload_end = payload_start + plen as usize;
|
||||
|
||||
if data.len() < payload_end + 1 {
|
||||
return Err(format!(
|
||||
"Incomplete response: need {} bytes, got {}",
|
||||
payload_end + 1,
|
||||
data.len()
|
||||
));
|
||||
}
|
||||
|
||||
let payload = data[payload_start..payload_end].to_vec();
|
||||
let expected_crc = data[payload_end];
|
||||
let actual_crc = crc8(&payload);
|
||||
|
||||
if expected_crc != actual_crc {
|
||||
return Err(format!(
|
||||
"CRC mismatch: expected 0x{:02X}, got 0x{:02X}",
|
||||
expected_crc, actual_crc
|
||||
));
|
||||
}
|
||||
|
||||
let consumed = payload_end + 1 - pos;
|
||||
Ok((KrResponse { cmd, status, payload }, consumed))
|
||||
}
|
||||
501
original-src/flasher.rs
Normal file
501
original-src/flasher.rs
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
/// ESP32 ROM bootloader flasher via serial (CH340/CP2102 programming port).
|
||||
/// Implements minimal SLIP-framed bootloader protocol for firmware flashing
|
||||
/// without requiring esptool.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use serialport::SerialPort;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::mpsc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ==================== SLIP framing ====================
|
||||
|
||||
const SLIP_END: u8 = 0xC0;
|
||||
const SLIP_ESC: u8 = 0xDB;
|
||||
const SLIP_ESC_END: u8 = 0xDC;
|
||||
const SLIP_ESC_ESC: u8 = 0xDD;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn slip_encode(data: &[u8]) -> Vec<u8> {
|
||||
let mut frame = Vec::with_capacity(data.len() + 10);
|
||||
frame.push(SLIP_END);
|
||||
for &byte in data {
|
||||
match byte {
|
||||
SLIP_END => {
|
||||
frame.push(SLIP_ESC);
|
||||
frame.push(SLIP_ESC_END);
|
||||
}
|
||||
SLIP_ESC => {
|
||||
frame.push(SLIP_ESC);
|
||||
frame.push(SLIP_ESC_ESC);
|
||||
}
|
||||
_ => frame.push(byte),
|
||||
}
|
||||
}
|
||||
frame.push(SLIP_END);
|
||||
frame
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn slip_decode(frame: &[u8]) -> Vec<u8> {
|
||||
let mut data = Vec::with_capacity(frame.len());
|
||||
let mut escaped = false;
|
||||
for &byte in frame {
|
||||
if escaped {
|
||||
match byte {
|
||||
SLIP_ESC_END => data.push(SLIP_END),
|
||||
SLIP_ESC_ESC => data.push(SLIP_ESC),
|
||||
_ => data.push(byte),
|
||||
}
|
||||
escaped = false;
|
||||
} else if byte == SLIP_ESC {
|
||||
escaped = true;
|
||||
} else if byte != SLIP_END {
|
||||
data.push(byte);
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
// ==================== Bootloader commands ====================
|
||||
|
||||
const CMD_SYNC: u8 = 0x08;
|
||||
const CMD_CHANGE_BAUDRATE: u8 = 0x0F;
|
||||
const CMD_SPI_ATTACH: u8 = 0x0D;
|
||||
const CMD_FLASH_BEGIN: u8 = 0x02;
|
||||
const CMD_FLASH_DATA: u8 = 0x03;
|
||||
const CMD_FLASH_END: u8 = 0x04;
|
||||
|
||||
const FLASH_BLOCK_SIZE: u32 = 1024;
|
||||
const INITIAL_BAUD: u32 = 115200;
|
||||
const FLASH_BAUD: u32 = 460800;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn xor_checksum(data: &[u8]) -> u32 {
|
||||
let mut chk: u8 = 0xEF;
|
||||
for &b in data {
|
||||
chk ^= b;
|
||||
}
|
||||
chk as u32
|
||||
}
|
||||
|
||||
/// Build a bootloader command packet (before SLIP encoding).
|
||||
/// Format: [0x00][cmd][size:u16 LE][checksum:u32 LE][data...]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_command(cmd: u8, data: &[u8], checksum: u32) -> Vec<u8> {
|
||||
let size = data.len() as u16;
|
||||
let mut pkt = Vec::with_capacity(8 + data.len());
|
||||
pkt.push(0x00); // direction: command
|
||||
pkt.push(cmd);
|
||||
pkt.push((size & 0xFF) as u8);
|
||||
pkt.push((size >> 8) as u8);
|
||||
pkt.push((checksum & 0xFF) as u8);
|
||||
pkt.push(((checksum >> 8) & 0xFF) as u8);
|
||||
pkt.push(((checksum >> 16) & 0xFF) as u8);
|
||||
pkt.push(((checksum >> 24) & 0xFF) as u8);
|
||||
pkt.extend_from_slice(data);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Extract complete SLIP frames from a byte buffer.
|
||||
/// Returns (frames, remaining_bytes_not_consumed).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn extract_slip_frames(raw: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut frames = Vec::new();
|
||||
let mut in_frame = false;
|
||||
let mut current = Vec::new();
|
||||
|
||||
for &byte in raw {
|
||||
if byte == SLIP_END {
|
||||
if in_frame && !current.is_empty() {
|
||||
// End of frame
|
||||
frames.push(current.clone());
|
||||
current.clear();
|
||||
in_frame = false;
|
||||
} else {
|
||||
// Start of frame (or consecutive 0xC0)
|
||||
in_frame = true;
|
||||
current.clear();
|
||||
}
|
||||
} else if in_frame {
|
||||
current.push(byte);
|
||||
}
|
||||
// If !in_frame and byte != SLIP_END, it's garbage — skip
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
/// Send a command and receive a valid response.
|
||||
/// Handles boot log garbage and multiple SYNC responses.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn send_command(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
cmd: u8,
|
||||
data: &[u8],
|
||||
checksum: u32,
|
||||
timeout_ms: u64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let pkt = build_command(cmd, data, checksum);
|
||||
let frame = slip_encode(&pkt);
|
||||
|
||||
port.write_all(&frame)
|
||||
.map_err(|e| format!("Write error: {}", e))?;
|
||||
port.flush()
|
||||
.map_err(|e| format!("Flush error: {}", e))?;
|
||||
|
||||
// Read bytes and extract SLIP frames, looking for a valid response
|
||||
let mut raw = Vec::new();
|
||||
let mut buf = [0u8; 512];
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
loop {
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed > timeout {
|
||||
let got = if raw.is_empty() {
|
||||
"nothing".to_string()
|
||||
} else {
|
||||
format!("{} raw bytes, no valid response", raw.len())
|
||||
};
|
||||
return Err(format!("Response timeout (got {})", got));
|
||||
}
|
||||
|
||||
let read_result = port.read(&mut buf);
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
raw.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a valid response in accumulated data
|
||||
let frames = extract_slip_frames(&raw);
|
||||
for slip_data in &frames {
|
||||
let decoded = slip_decode(slip_data);
|
||||
|
||||
if decoded.len() < 8 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let direction = decoded[0];
|
||||
let resp_cmd = decoded[1];
|
||||
|
||||
if direction != 0x01 || resp_cmd != cmd {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ROM bootloader status is at offset 8 (right after 8-byte header)
|
||||
// Format: [dir][cmd][size:u16][value:u32][status][error][pad][pad]
|
||||
if decoded.len() >= 10 {
|
||||
let status = decoded[8];
|
||||
let error = decoded[9];
|
||||
if status != 0 {
|
||||
return Err(format!("Bootloader error: cmd=0x{:02X} status={}, error={} (0x{:02X})",
|
||||
cmd, status, error, error));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Bootloader entry ====================
|
||||
|
||||
/// Toggle DTR/RTS to reset ESP32 into bootloader mode.
|
||||
/// Standard auto-reset circuit: DTR→EN, RTS→GPIO0.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn enter_bootloader(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
// Hold GPIO0 low (RTS=true) while pulsing EN (DTR)
|
||||
port.write_data_terminal_ready(false)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
port.write_request_to_send(true)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
// Release EN (DTR=true) while keeping GPIO0 low
|
||||
port.write_data_terminal_ready(true)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
port.write_request_to_send(false)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Release all
|
||||
port.write_data_terminal_ready(false)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
|
||||
// Drain any boot message
|
||||
let _ = port.clear(serialport::ClearBuffer::All);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== High-level commands ====================
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn sync(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
// SYNC payload: [0x07, 0x07, 0x12, 0x20] + 32 x 0x55
|
||||
let mut payload = vec![0x07, 0x07, 0x12, 0x20];
|
||||
payload.extend_from_slice(&[0x55; 32]);
|
||||
|
||||
for attempt in 0..10 {
|
||||
let result = send_command(port, CMD_SYNC, &payload, 0, 500);
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) if attempt < 9 => {
|
||||
// Drain any pending data before retry
|
||||
let _ = port.clear(serialport::ClearBuffer::Input);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => return Err(format!("SYNC failed after 10 attempts: {}", e)),
|
||||
}
|
||||
}
|
||||
Err("SYNC failed".into())
|
||||
}
|
||||
|
||||
/// Tell the bootloader to switch to a faster baud rate, then reconnect.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn change_baudrate(port: &mut Box<dyn SerialPort>, new_baud: u32) -> Result<(), String> {
|
||||
// Payload: [new_baud:u32 LE][old_baud:u32 LE] (old_baud=0 means "current")
|
||||
let mut payload = Vec::with_capacity(8);
|
||||
payload.extend_from_slice(&new_baud.to_le_bytes());
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
|
||||
send_command(port, CMD_CHANGE_BAUDRATE, &payload, 0, 3000)?;
|
||||
|
||||
// Switch host side to new baud
|
||||
port.set_baud_rate(new_baud)
|
||||
.map_err(|e| format!("Set baud error: {}", e))?;
|
||||
|
||||
// Small delay for baud switch to take effect
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let _ = port.clear(serialport::ClearBuffer::All);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spi_attach(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
let payload = [0u8; 8];
|
||||
send_command(port, CMD_SPI_ATTACH, &payload, 0, 3000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_begin(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
offset: u32,
|
||||
total_size: u32,
|
||||
block_size: u32,
|
||||
) -> Result<(), String> {
|
||||
let num_blocks = (total_size + block_size - 1) / block_size;
|
||||
|
||||
let mut payload = Vec::with_capacity(20);
|
||||
// erase_size
|
||||
payload.extend_from_slice(&total_size.to_le_bytes());
|
||||
// num_blocks
|
||||
payload.extend_from_slice(&num_blocks.to_le_bytes());
|
||||
// block_size
|
||||
payload.extend_from_slice(&block_size.to_le_bytes());
|
||||
// offset
|
||||
payload.extend_from_slice(&offset.to_le_bytes());
|
||||
// encrypted (ESP32-S3 requires this 5th field — 0 = not encrypted)
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
|
||||
// FLASH_BEGIN can take a while (flash erase) — long timeout
|
||||
send_command(port, CMD_FLASH_BEGIN, &payload, 0, 30_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_data(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
seq: u32,
|
||||
data: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let data_len = data.len() as u32;
|
||||
|
||||
let mut payload = Vec::with_capacity(16 + data.len());
|
||||
// data length
|
||||
payload.extend_from_slice(&data_len.to_le_bytes());
|
||||
// sequence number
|
||||
payload.extend_from_slice(&seq.to_le_bytes());
|
||||
// reserved (2 x u32)
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
// data
|
||||
payload.extend_from_slice(data);
|
||||
|
||||
let checksum = xor_checksum(data);
|
||||
send_command(port, CMD_FLASH_DATA, &payload, checksum, 10_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_end(port: &mut Box<dyn SerialPort>, reboot: bool) -> Result<(), String> {
|
||||
let flag: u32 = if reboot { 0 } else { 1 };
|
||||
let payload = flag.to_le_bytes();
|
||||
// FLASH_END might not get a response if device reboots
|
||||
let _ = send_command(port, CMD_FLASH_END, &payload, 0, 2000);
|
||||
|
||||
if reboot {
|
||||
// Hard reset: toggle RTS to pulse EN pin (like esptool --after hard_reset)
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
port.write_request_to_send(true)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
port.write_request_to_send(false)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Main entry point ====================
|
||||
|
||||
/// Flash firmware to ESP32 via programming port (CH340/CP2102).
|
||||
/// Sends progress updates via the channel as (progress_0_to_1, status_message).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn flash_firmware(
|
||||
port_name: &str,
|
||||
firmware: &[u8],
|
||||
offset: u32,
|
||||
tx: &mpsc::Sender<super::ui::BgResult>,
|
||||
) -> Result<(), String> {
|
||||
let send_progress = |progress: f32, msg: String| {
|
||||
let _ = tx.send(super::ui::BgResult::OtaProgress(progress, msg));
|
||||
};
|
||||
|
||||
send_progress(0.0, "Opening port...".into());
|
||||
|
||||
let builder = serialport::new(port_name, INITIAL_BAUD);
|
||||
let builder_timeout = builder.timeout(Duration::from_millis(500));
|
||||
let mut port = builder_timeout.open()
|
||||
.map_err(|e| format!("Cannot open {}: {}", port_name, e))?;
|
||||
|
||||
// Step 1: Enter bootloader
|
||||
send_progress(0.0, "Resetting into bootloader...".into());
|
||||
enter_bootloader(&mut port)?;
|
||||
|
||||
// Step 2: Sync at 115200
|
||||
send_progress(0.0, "Syncing with bootloader...".into());
|
||||
sync(&mut port)?;
|
||||
send_progress(0.02, "Bootloader sync OK".into());
|
||||
|
||||
// Step 3: Switch to 460800 baud for faster flashing
|
||||
send_progress(0.03, format!("Switching to {} baud...", FLASH_BAUD));
|
||||
change_baudrate(&mut port, FLASH_BAUD)?;
|
||||
send_progress(0.04, format!("Baud: {}", FLASH_BAUD));
|
||||
|
||||
// Step 4: SPI attach
|
||||
send_progress(0.05, "Attaching SPI flash...".into());
|
||||
spi_attach(&mut port)?;
|
||||
|
||||
// Step 5: Flash begin (this erases the flash — can take several seconds)
|
||||
let total_size = firmware.len() as u32;
|
||||
let num_blocks = (total_size + FLASH_BLOCK_SIZE - 1) / FLASH_BLOCK_SIZE;
|
||||
send_progress(0.05, format!("Erasing flash ({} KB)...", total_size / 1024));
|
||||
flash_begin(&mut port, offset, total_size, FLASH_BLOCK_SIZE)?;
|
||||
send_progress(0.10, "Flash erased, writing...".into());
|
||||
|
||||
// Step 6: Flash data blocks
|
||||
for (i, chunk) in firmware.chunks(FLASH_BLOCK_SIZE as usize).enumerate() {
|
||||
// Pad last block to block_size
|
||||
let mut block = chunk.to_vec();
|
||||
let pad_needed = FLASH_BLOCK_SIZE as usize - block.len();
|
||||
if pad_needed > 0 {
|
||||
block.extend(std::iter::repeat(0xFF).take(pad_needed));
|
||||
}
|
||||
|
||||
flash_data(&mut port, i as u32, &block)?;
|
||||
|
||||
let blocks_done = (i + 1) as f32;
|
||||
let total_blocks = num_blocks as f32;
|
||||
let progress = 0.10 + 0.85 * (blocks_done / total_blocks);
|
||||
let msg = format!("Writing block {}/{} ({} KB / {} KB)",
|
||||
i + 1, num_blocks,
|
||||
((i + 1) as u32 * FLASH_BLOCK_SIZE).min(total_size) / 1024,
|
||||
total_size / 1024);
|
||||
send_progress(progress, msg);
|
||||
}
|
||||
|
||||
// Step 7: Flash end + reboot
|
||||
send_progress(0.97, "Finalizing...".into());
|
||||
flash_end(&mut port, true)?;
|
||||
|
||||
send_progress(1.0, format!("Flash OK — {} KB written at 0x{:X}", total_size / 1024, offset));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slip_encode_no_special() {
|
||||
let data = vec![0x01, 0x02, 0x03];
|
||||
let encoded = slip_encode(&data);
|
||||
assert_eq!(encoded, vec![0xC0, 0x01, 0x02, 0x03, 0xC0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slip_encode_with_end_byte() {
|
||||
let data = vec![0x01, 0xC0, 0x03];
|
||||
let encoded = slip_encode(&data);
|
||||
assert_eq!(encoded, vec![0xC0, 0x01, 0xDB, 0xDC, 0x03, 0xC0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slip_encode_with_esc_byte() {
|
||||
let data = vec![0x01, 0xDB, 0x03];
|
||||
let encoded = slip_encode(&data);
|
||||
assert_eq!(encoded, vec![0xC0, 0x01, 0xDB, 0xDD, 0x03, 0xC0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slip_roundtrip() {
|
||||
let original = vec![0xC0, 0xDB, 0x00, 0xFF, 0xC0];
|
||||
let encoded = slip_encode(&original);
|
||||
let decoded = slip_decode(&encoded);
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_checksum_basic() {
|
||||
let data = vec![0x01, 0x02, 0x03];
|
||||
let chk = xor_checksum(&data);
|
||||
let expected = 0xEF ^ 0x01 ^ 0x02 ^ 0x03;
|
||||
assert_eq!(chk, expected as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_checksum_empty() {
|
||||
let chk = xor_checksum(&[]);
|
||||
assert_eq!(chk, 0xEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_command_format() {
|
||||
let data = vec![0xAA, 0xBB];
|
||||
let pkt = build_command(0x08, &data, 0x12345678);
|
||||
assert_eq!(pkt[0], 0x00); // direction
|
||||
assert_eq!(pkt[1], 0x08); // command
|
||||
assert_eq!(pkt[2], 0x02); // size low
|
||||
assert_eq!(pkt[3], 0x00); // size high
|
||||
assert_eq!(pkt[4], 0x78); // checksum byte 0
|
||||
assert_eq!(pkt[5], 0x56); // checksum byte 1
|
||||
assert_eq!(pkt[6], 0x34); // checksum byte 2
|
||||
assert_eq!(pkt[7], 0x12); // checksum byte 3
|
||||
assert_eq!(pkt[8], 0xAA); // data
|
||||
assert_eq!(pkt[9], 0xBB);
|
||||
}
|
||||
}
|
||||
390
original-src/keycode.rs
Normal file
390
original-src/keycode.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
/// Decode a raw 16-bit keycode into a human-readable string.
|
||||
///
|
||||
/// Covers all KaSe firmware keycode ranges: HID basic keys, layer switches,
|
||||
/// macros, Bluetooth, one-shot, mod-tap, layer-tap, tap-dance, and more.
|
||||
pub fn decode_keycode(raw: u16) -> String {
|
||||
// --- HID basic keycodes 0x00..=0xE7 ---
|
||||
if raw <= 0x00E7 {
|
||||
return hid_key_name(raw as u8);
|
||||
}
|
||||
|
||||
// --- MO (Momentary Layer): 0x0100..=0x0A00, low byte == 0 ---
|
||||
if raw >= 0x0100 && raw <= 0x0A00 && (raw & 0xFF) == 0 {
|
||||
let layer = (raw >> 8) - 1;
|
||||
return format!("MO {layer}");
|
||||
}
|
||||
|
||||
// --- TO (Toggle Layer): 0x0B00..=0x1400, low byte == 0 ---
|
||||
if raw >= 0x0B00 && raw <= 0x1400 && (raw & 0xFF) == 0 {
|
||||
let layer = (raw >> 8) - 0x0B;
|
||||
return format!("TO {layer}");
|
||||
}
|
||||
|
||||
// --- MACRO: 0x1500..=0x2800, low byte == 0 ---
|
||||
if raw >= 0x1500 && raw <= 0x2800 && (raw & 0xFF) == 0 {
|
||||
let idx = (raw >> 8) - 0x14;
|
||||
return format!("M{idx}");
|
||||
}
|
||||
|
||||
// --- BT keycodes ---
|
||||
match raw {
|
||||
0x2900 => return "BT Next".into(),
|
||||
0x2A00 => return "BT Prev".into(),
|
||||
0x2B00 => return "BT Pair".into(),
|
||||
0x2C00 => return "BT Disc".into(),
|
||||
0x2E00 => return "USB/BT".into(),
|
||||
0x2F00 => return "BT On/Off".into(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// --- OSM (One-Shot Mod): 0x3000..=0x30FF ---
|
||||
if raw >= 0x3000 && raw <= 0x30FF {
|
||||
let mods = (raw & 0xFF) as u8;
|
||||
return format!("OSM {}", mod_name(mods));
|
||||
}
|
||||
|
||||
// --- OSL (One-Shot Layer): 0x3100..=0x310F ---
|
||||
if raw >= 0x3100 && raw <= 0x310F {
|
||||
let layer = raw & 0x0F;
|
||||
return format!("OSL {layer}");
|
||||
}
|
||||
|
||||
// --- Fixed special codes ---
|
||||
match raw {
|
||||
0x3200 => return "Caps Word".into(),
|
||||
0x3300 => return "Repeat".into(),
|
||||
0x3400 => return "Leader".into(),
|
||||
0x3500 => return "Feed".into(),
|
||||
0x3600 => return "Play".into(),
|
||||
0x3700 => return "Sleep".into(),
|
||||
0x3800 => return "Meds".into(),
|
||||
0x3900 => return "GEsc".into(),
|
||||
0x3A00 => return "Layer Lock".into(),
|
||||
0x3C00 => return "AS Toggle".into(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// --- KO (Key Override) slots: 0x3D00..=0x3DFF ---
|
||||
if raw >= 0x3D00 && raw <= 0x3DFF {
|
||||
let slot = raw & 0xFF;
|
||||
return format!("KO {slot}");
|
||||
}
|
||||
|
||||
// --- LT (Layer-Tap): 0x4000..=0x4FFF ---
|
||||
// layout: 0x4LKK where L = layer (0..F), KK = HID keycode
|
||||
if raw >= 0x4000 && raw <= 0x4FFF {
|
||||
let layer = (raw >> 8) & 0x0F;
|
||||
let kc = (raw & 0xFF) as u8;
|
||||
return format!("LT {} {}", layer, hid_key_name(kc));
|
||||
}
|
||||
|
||||
// --- MT (Mod-Tap): 0x5000..=0x5FFF ---
|
||||
// layout: 0x5MKK where M = mod nibble (4 bits), KK = HID keycode
|
||||
if raw >= 0x5000 && raw <= 0x5FFF {
|
||||
let mods = ((raw >> 8) & 0x0F) as u8;
|
||||
let kc = (raw & 0xFF) as u8;
|
||||
return format!("MT {} {}", mod_name(mods), hid_key_name(kc));
|
||||
}
|
||||
|
||||
// --- TD (Tap Dance): 0x6000..=0x6FFF ---
|
||||
if raw >= 0x6000 && raw <= 0x6FFF {
|
||||
let index = (raw >> 8) & 0x0F;
|
||||
return format!("TD {index}");
|
||||
}
|
||||
|
||||
// --- Unknown ---
|
||||
format!("0x{raw:04X}")
|
||||
}
|
||||
|
||||
/// Decode a modifier bitmask into a human-readable string.
|
||||
///
|
||||
/// Bits: 0x01=Ctrl, 0x02=Shift, 0x04=Alt, 0x08=GUI,
|
||||
/// 0x10=RCtrl, 0x20=RShift, 0x40=RAlt, 0x80=RGUI.
|
||||
/// Multiple modifiers are joined with "+".
|
||||
pub fn mod_name(mod_mask: u8) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if mod_mask & 0x01 != 0 { parts.push("Ctrl"); }
|
||||
if mod_mask & 0x02 != 0 { parts.push("Shift"); }
|
||||
if mod_mask & 0x04 != 0 { parts.push("Alt"); }
|
||||
if mod_mask & 0x08 != 0 { parts.push("GUI"); }
|
||||
if mod_mask & 0x10 != 0 { parts.push("RCtrl"); }
|
||||
if mod_mask & 0x20 != 0 { parts.push("RShift"); }
|
||||
if mod_mask & 0x40 != 0 { parts.push("RAlt"); }
|
||||
if mod_mask & 0x80 != 0 { parts.push("RGUI"); }
|
||||
if parts.is_empty() {
|
||||
format!("0x{mod_mask:02X}")
|
||||
} else {
|
||||
parts.join("+")
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a single HID usage code (0x00..=0xE7) to a short readable name.
|
||||
pub fn hid_key_name(code: u8) -> String {
|
||||
match code {
|
||||
// No key / transparent
|
||||
0x00 => "None",
|
||||
// 0x01 = ErrorRollOver, 0x02 = POSTFail, 0x03 = ErrorUndefined (not user-facing)
|
||||
0x01 => "ErrRollOver",
|
||||
0x02 => "POSTFail",
|
||||
0x03 => "ErrUndef",
|
||||
|
||||
// Letters
|
||||
0x04 => "A",
|
||||
0x05 => "B",
|
||||
0x06 => "C",
|
||||
0x07 => "D",
|
||||
0x08 => "E",
|
||||
0x09 => "F",
|
||||
0x0A => "G",
|
||||
0x0B => "H",
|
||||
0x0C => "I",
|
||||
0x0D => "J",
|
||||
0x0E => "K",
|
||||
0x0F => "L",
|
||||
0x10 => "M",
|
||||
0x11 => "N",
|
||||
0x12 => "O",
|
||||
0x13 => "P",
|
||||
0x14 => "Q",
|
||||
0x15 => "R",
|
||||
0x16 => "S",
|
||||
0x17 => "T",
|
||||
0x18 => "U",
|
||||
0x19 => "V",
|
||||
0x1A => "W",
|
||||
0x1B => "X",
|
||||
0x1C => "Y",
|
||||
0x1D => "Z",
|
||||
|
||||
// Number row
|
||||
0x1E => "1",
|
||||
0x1F => "2",
|
||||
0x20 => "3",
|
||||
0x21 => "4",
|
||||
0x22 => "5",
|
||||
0x23 => "6",
|
||||
0x24 => "7",
|
||||
0x25 => "8",
|
||||
0x26 => "9",
|
||||
0x27 => "0",
|
||||
|
||||
// Common control keys
|
||||
0x28 => "Enter",
|
||||
0x29 => "Esc",
|
||||
0x2A => "Backspace",
|
||||
0x2B => "Tab",
|
||||
0x2C => "Space",
|
||||
|
||||
// Punctuation / symbols
|
||||
0x2D => "-",
|
||||
0x2E => "=",
|
||||
0x2F => "[",
|
||||
0x30 => "]",
|
||||
0x31 => "\\",
|
||||
0x32 => "Europe1",
|
||||
0x33 => ";",
|
||||
0x34 => "'",
|
||||
0x35 => "`",
|
||||
0x36 => ",",
|
||||
0x37 => ".",
|
||||
0x38 => "/",
|
||||
|
||||
// Caps Lock
|
||||
0x39 => "Caps Lock",
|
||||
|
||||
// Function keys
|
||||
0x3A => "F1",
|
||||
0x3B => "F2",
|
||||
0x3C => "F3",
|
||||
0x3D => "F4",
|
||||
0x3E => "F5",
|
||||
0x3F => "F6",
|
||||
0x40 => "F7",
|
||||
0x41 => "F8",
|
||||
0x42 => "F9",
|
||||
0x43 => "F10",
|
||||
0x44 => "F11",
|
||||
0x45 => "F12",
|
||||
|
||||
// Navigation / editing cluster
|
||||
0x46 => "PrtSc",
|
||||
0x47 => "ScrLk",
|
||||
0x48 => "Pause",
|
||||
0x49 => "Ins",
|
||||
0x4A => "Home",
|
||||
0x4B => "PgUp",
|
||||
0x4C => "Del",
|
||||
0x4D => "End",
|
||||
0x4E => "PgDn",
|
||||
|
||||
// Arrow keys
|
||||
0x4F => "Right",
|
||||
0x50 => "Left",
|
||||
0x51 => "Down",
|
||||
0x52 => "Up",
|
||||
|
||||
// Keypad
|
||||
0x53 => "NumLk",
|
||||
0x54 => "Num /",
|
||||
0x55 => "Num *",
|
||||
0x56 => "Num -",
|
||||
0x57 => "Num +",
|
||||
0x58 => "Num Enter",
|
||||
0x59 => "Num 1",
|
||||
0x5A => "Num 2",
|
||||
0x5B => "Num 3",
|
||||
0x5C => "Num 4",
|
||||
0x5D => "Num 5",
|
||||
0x5E => "Num 6",
|
||||
0x5F => "Num 7",
|
||||
0x60 => "Num 8",
|
||||
0x61 => "Num 9",
|
||||
0x62 => "Num 0",
|
||||
0x63 => "Num .",
|
||||
0x64 => "Europe2",
|
||||
0x65 => "Menu",
|
||||
0x66 => "Power",
|
||||
0x67 => "Num =",
|
||||
|
||||
// F13-F24
|
||||
0x68 => "F13",
|
||||
0x69 => "F14",
|
||||
0x6A => "F15",
|
||||
0x6B => "F16",
|
||||
0x6C => "F17",
|
||||
0x6D => "F18",
|
||||
0x6E => "F19",
|
||||
0x6F => "F20",
|
||||
0x70 => "F21",
|
||||
0x71 => "F22",
|
||||
0x72 => "F23",
|
||||
0x73 => "F24",
|
||||
|
||||
// Misc system keys
|
||||
0x74 => "Execute",
|
||||
0x75 => "Help",
|
||||
0x76 => "Menu2",
|
||||
0x77 => "Select",
|
||||
0x78 => "Stop",
|
||||
0x79 => "Again",
|
||||
0x7A => "Undo",
|
||||
0x7B => "Cut",
|
||||
0x7C => "Copy",
|
||||
0x7D => "Paste",
|
||||
0x7E => "Find",
|
||||
0x7F => "Mute",
|
||||
0x80 => "Vol Up",
|
||||
0x81 => "Vol Down",
|
||||
|
||||
// Locking keys
|
||||
0x82 => "Lock Caps",
|
||||
0x83 => "Lock Num",
|
||||
0x84 => "Lock Scroll",
|
||||
|
||||
// Keypad extras
|
||||
0x85 => "Num ,",
|
||||
0x86 => "Num =2",
|
||||
|
||||
// International / Kanji
|
||||
0x87 => "Kanji1",
|
||||
0x88 => "Kanji2",
|
||||
0x89 => "Kanji3",
|
||||
0x8A => "Kanji4",
|
||||
0x8B => "Kanji5",
|
||||
0x8C => "Kanji6",
|
||||
0x8D => "Kanji7",
|
||||
0x8E => "Kanji8",
|
||||
0x8F => "Kanji9",
|
||||
|
||||
// Language keys
|
||||
0x90 => "Lang1",
|
||||
0x91 => "Lang2",
|
||||
0x92 => "Lang3",
|
||||
0x93 => "Lang4",
|
||||
0x94 => "Lang5",
|
||||
0x95 => "Lang6",
|
||||
0x96 => "Lang7",
|
||||
0x97 => "Lang8",
|
||||
0x98 => "Lang9",
|
||||
|
||||
// Rare system keys
|
||||
0x99 => "Alt Erase",
|
||||
0x9A => "SysReq",
|
||||
0x9B => "Cancel",
|
||||
0x9C => "Clear",
|
||||
0x9D => "Prior",
|
||||
0x9E => "Return",
|
||||
0x9F => "Separator",
|
||||
0xA0 => "Out",
|
||||
0xA1 => "Oper",
|
||||
0xA2 => "Clear Again",
|
||||
0xA3 => "CrSel",
|
||||
0xA4 => "ExSel",
|
||||
|
||||
// 0xA5..=0xAF reserved / not defined in standard HID tables
|
||||
|
||||
// Extended keypad
|
||||
0xB0 => "Num 00",
|
||||
0xB1 => "Num 000",
|
||||
0xB2 => "Thousands Sep",
|
||||
0xB3 => "Decimal Sep",
|
||||
0xB4 => "Currency",
|
||||
0xB5 => "Currency Sub",
|
||||
0xB6 => "Num (",
|
||||
0xB7 => "Num )",
|
||||
0xB8 => "Num {",
|
||||
0xB9 => "Num }",
|
||||
0xBA => "Num Tab",
|
||||
0xBB => "Num Bksp",
|
||||
0xBC => "Num A",
|
||||
0xBD => "Num B",
|
||||
0xBE => "Num C",
|
||||
0xBF => "Num D",
|
||||
0xC0 => "Num E",
|
||||
0xC1 => "Num F",
|
||||
0xC2 => "Num XOR",
|
||||
0xC3 => "Num ^",
|
||||
0xC4 => "Num %",
|
||||
0xC5 => "Num <",
|
||||
0xC6 => "Num >",
|
||||
0xC7 => "Num &",
|
||||
0xC8 => "Num &&",
|
||||
0xC9 => "Num |",
|
||||
0xCA => "Num ||",
|
||||
0xCB => "Num :",
|
||||
0xCC => "Num #",
|
||||
0xCD => "Num Space",
|
||||
0xCE => "Num @",
|
||||
0xCF => "Num !",
|
||||
0xD0 => "Num M Store",
|
||||
0xD1 => "Num M Recall",
|
||||
0xD2 => "Num M Clear",
|
||||
0xD3 => "Num M+",
|
||||
0xD4 => "Num M-",
|
||||
0xD5 => "Num M*",
|
||||
0xD6 => "Num M/",
|
||||
0xD7 => "Num +/-",
|
||||
0xD8 => "Num Clear",
|
||||
0xD9 => "Num ClrEntry",
|
||||
0xDA => "Num Binary",
|
||||
0xDB => "Num Octal",
|
||||
0xDC => "Num Decimal",
|
||||
0xDD => "Num Hex",
|
||||
|
||||
// 0xDE..=0xDF reserved
|
||||
|
||||
// Modifier keys
|
||||
0xE0 => "LCtrl",
|
||||
0xE1 => "LShift",
|
||||
0xE2 => "LAlt",
|
||||
0xE3 => "LGUI",
|
||||
0xE4 => "RCtrl",
|
||||
0xE5 => "RShift",
|
||||
0xE6 => "RAlt",
|
||||
0xE7 => "RGUI",
|
||||
|
||||
// Anything else in 0x00..=0xFF not covered above
|
||||
_ => return format!("0x{code:02X}"),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
286
original-src/layout.rs
Normal file
286
original-src/layout.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
use serde_json::Value;
|
||||
|
||||
/// A keycap with computed absolute position.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct KeycapPos {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
pub angle: f32, // degrees
|
||||
}
|
||||
|
||||
const KEY_SIZE: f32 = 50.0;
|
||||
const KEY_GAP: f32 = 4.0;
|
||||
|
||||
/// Parse a layout JSON string into absolute key positions.
|
||||
pub fn parse_json(json: &str) -> Result<Vec<KeycapPos>, String> {
|
||||
let val: Value = serde_json::from_str(json)
|
||||
.map_err(|e| format!("Invalid layout JSON: {}", e))?;
|
||||
let mut keys = Vec::new();
|
||||
walk(&val, 0.0, 0.0, 0.0, &mut keys);
|
||||
if keys.is_empty() {
|
||||
return Err("No keys found in layout".into());
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Default layout embedded at compile time.
|
||||
pub fn default_layout() -> Vec<KeycapPos> {
|
||||
let json = include_str!("../default.json");
|
||||
parse_json(json).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn walk(node: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match node.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
for (key, val) in obj {
|
||||
let key_str = key.as_str();
|
||||
match key_str {
|
||||
"Group" => walk_group(val, ox, oy, parent_angle, out),
|
||||
"Line" => walk_line(val, ox, oy, parent_angle, out),
|
||||
"Keycap" => walk_keycap(val, ox, oy, parent_angle, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_margin(val: &Value) -> (f32, f32, f32, f32) {
|
||||
let as_str = val.as_str();
|
||||
if let Some(s) = as_str {
|
||||
let split = s.split(',');
|
||||
let parts: Vec<f32> = split
|
||||
.filter_map(|p| {
|
||||
let trimmed = p.trim();
|
||||
let parsed = trimmed.parse().ok();
|
||||
parsed
|
||||
})
|
||||
.collect();
|
||||
let has_four_parts = parts.len() == 4;
|
||||
if has_four_parts {
|
||||
return (parts[0], parts[1], parts[2], parts[3]);
|
||||
}
|
||||
}
|
||||
(0.0, 0.0, 0.0, 0.0)
|
||||
}
|
||||
|
||||
fn parse_angle(val: &Value) -> f32 {
|
||||
let rotate_transform = val.get("RotateTransform");
|
||||
let angle_val = rotate_transform
|
||||
.and_then(|rt| rt.get("Angle"));
|
||||
let angle_f64 = angle_val
|
||||
.and_then(|a| a.as_f64());
|
||||
let angle = angle_f64.unwrap_or(0.0) as f32;
|
||||
angle
|
||||
}
|
||||
|
||||
fn walk_group(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let gx = ox + ml;
|
||||
let gy = oy + mt;
|
||||
|
||||
let children_val = obj.get("Children");
|
||||
let children_array = children_val
|
||||
.and_then(|c| c.as_array());
|
||||
if let Some(children) = children_array {
|
||||
let combined_angle = parent_angle + angle;
|
||||
for child in children {
|
||||
walk(child, gx, gy, combined_angle, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_line(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
let total_angle = parent_angle + angle;
|
||||
|
||||
let orientation_val = obj.get("Orientation");
|
||||
let orientation_str = orientation_val
|
||||
.and_then(|o| o.as_str())
|
||||
.unwrap_or("Vertical");
|
||||
let horiz = orientation_str == "Horizontal";
|
||||
|
||||
let lx = ox + ml;
|
||||
let ly = oy + mt;
|
||||
|
||||
let rad = total_angle.to_radians();
|
||||
let cos_a = rad.cos();
|
||||
let sin_a = rad.sin();
|
||||
|
||||
let mut cursor = 0.0f32;
|
||||
|
||||
let children_val = obj.get("Children");
|
||||
let children_array = children_val
|
||||
.and_then(|c| c.as_array());
|
||||
if let Some(children) = children_array {
|
||||
for child in children {
|
||||
let (cx, cy) = if horiz {
|
||||
let x = lx + cursor * cos_a;
|
||||
let y = ly + cursor * sin_a;
|
||||
(x, y)
|
||||
} else {
|
||||
let x = lx - cursor * sin_a;
|
||||
let y = ly + cursor * cos_a;
|
||||
(x, y)
|
||||
};
|
||||
|
||||
let child_size = measure(child, horiz);
|
||||
walk(child, cx, cy, total_angle, out);
|
||||
cursor += child_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure a child's extent along the parent's main axis.
|
||||
fn measure(node: &Value, horiz: bool) -> f32 {
|
||||
let obj = match node.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
|
||||
for (key, val) in obj {
|
||||
let key_str = key.as_str();
|
||||
match key_str {
|
||||
"Keycap" => {
|
||||
let width_val = val.get("Width");
|
||||
let width_f64 = width_val
|
||||
.and_then(|v| v.as_f64());
|
||||
let w = width_f64.unwrap_or(KEY_SIZE as f64) as f32;
|
||||
let extent = if horiz {
|
||||
w + KEY_GAP
|
||||
} else {
|
||||
KEY_SIZE + KEY_GAP
|
||||
};
|
||||
return extent;
|
||||
}
|
||||
"Line" => {
|
||||
let sub = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
let sub_orientation = sub.get("Orientation");
|
||||
let sub_orient_str = sub_orientation
|
||||
.and_then(|o| o.as_str())
|
||||
.unwrap_or("Vertical");
|
||||
let sub_horiz = sub_orient_str == "Horizontal";
|
||||
|
||||
let sub_children_val = sub.get("Children");
|
||||
let sub_children_array = sub_children_val
|
||||
.and_then(|c| c.as_array());
|
||||
let children = sub_children_array
|
||||
.map(|a| a.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
let same_direction = sub_horiz == horiz;
|
||||
let content: f32 = if same_direction {
|
||||
// Same direction: sum
|
||||
children
|
||||
.iter()
|
||||
.map(|c| measure(c, sub_horiz))
|
||||
.sum()
|
||||
} else {
|
||||
// Cross direction: max
|
||||
children
|
||||
.iter()
|
||||
.map(|c| measure(c, horiz))
|
||||
.fold(0.0f32, f32::max)
|
||||
};
|
||||
|
||||
return content;
|
||||
}
|
||||
"Group" => {
|
||||
let sub = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
let sub_children_val = sub.get("Children");
|
||||
let sub_children_array = sub_children_val
|
||||
.and_then(|c| c.as_array());
|
||||
let children = sub_children_array
|
||||
.map(|a| a.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let max_extent = children
|
||||
.iter()
|
||||
.map(|c| measure(c, horiz))
|
||||
.fold(0.0f32, f32::max);
|
||||
return max_extent;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
fn walk_keycap(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let col_val = obj.get("Column");
|
||||
let col_u64 = col_val
|
||||
.and_then(|v| v.as_u64());
|
||||
let col = col_u64.unwrap_or(0) as usize;
|
||||
|
||||
let row_val = obj.get("Row");
|
||||
let row_u64 = row_val
|
||||
.and_then(|v| v.as_u64());
|
||||
let row = row_u64.unwrap_or(0) as usize;
|
||||
|
||||
let width_val = obj.get("Width");
|
||||
let width_f64 = width_val
|
||||
.and_then(|v| v.as_f64());
|
||||
let w = width_f64.unwrap_or(KEY_SIZE as f64) as f32;
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let total_angle = parent_angle + angle;
|
||||
|
||||
out.push(KeycapPos {
|
||||
row,
|
||||
col,
|
||||
x: ox + ml,
|
||||
y: oy + mt,
|
||||
w,
|
||||
h: KEY_SIZE,
|
||||
angle: total_angle,
|
||||
});
|
||||
}
|
||||
339
original-src/layout_remap.rs
Normal file
339
original-src/layout_remap.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
/// Remaps HID key names to their visual representation based on the user's
|
||||
/// keyboard layout (language). Ported from the C# `KeyConverter.LayoutOverrides`.
|
||||
|
||||
// Display implementation for the keyboard layout picker in settings.
|
||||
impl std::fmt::Display for KeyboardLayout {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.name())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum KeyboardLayout {
|
||||
Qwerty,
|
||||
Azerty,
|
||||
Qwertz,
|
||||
Dvorak,
|
||||
Colemak,
|
||||
Bepo,
|
||||
QwertyEs,
|
||||
QwertyPt,
|
||||
QwertyIt,
|
||||
QwertyNordic,
|
||||
QwertyBr,
|
||||
QwertyTr,
|
||||
QwertyUk,
|
||||
}
|
||||
|
||||
impl KeyboardLayout {
|
||||
/// All known layout variants.
|
||||
pub fn all() -> &'static [KeyboardLayout] {
|
||||
&[
|
||||
KeyboardLayout::Qwerty,
|
||||
KeyboardLayout::Azerty,
|
||||
KeyboardLayout::Qwertz,
|
||||
KeyboardLayout::Dvorak,
|
||||
KeyboardLayout::Colemak,
|
||||
KeyboardLayout::Bepo,
|
||||
KeyboardLayout::QwertyEs,
|
||||
KeyboardLayout::QwertyPt,
|
||||
KeyboardLayout::QwertyIt,
|
||||
KeyboardLayout::QwertyNordic,
|
||||
KeyboardLayout::QwertyBr,
|
||||
KeyboardLayout::QwertyTr,
|
||||
KeyboardLayout::QwertyUk,
|
||||
]
|
||||
}
|
||||
|
||||
/// Human-readable display name.
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
KeyboardLayout::Qwerty => "QWERTY",
|
||||
KeyboardLayout::Azerty => "AZERTY",
|
||||
KeyboardLayout::Qwertz => "QWERTZ",
|
||||
KeyboardLayout::Dvorak => "DVORAK",
|
||||
KeyboardLayout::Colemak => "COLEMAK",
|
||||
KeyboardLayout::Bepo => "BEPO",
|
||||
KeyboardLayout::QwertyEs => "QWERTY_ES",
|
||||
KeyboardLayout::QwertyPt => "QWERTY_PT",
|
||||
KeyboardLayout::QwertyIt => "QWERTY_IT",
|
||||
KeyboardLayout::QwertyNordic => "QWERTY_NORDIC",
|
||||
KeyboardLayout::QwertyBr => "QWERTY_BR",
|
||||
KeyboardLayout::QwertyTr => "QWERTY_TR",
|
||||
KeyboardLayout::QwertyUk => "QWERTY_UK",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a layout name (case-insensitive). Falls back to `Qwerty`.
|
||||
pub fn from_name(s: &str) -> Self {
|
||||
match s.to_ascii_uppercase().as_str() {
|
||||
"QWERTY" => KeyboardLayout::Qwerty,
|
||||
"AZERTY" => KeyboardLayout::Azerty,
|
||||
"QWERTZ" => KeyboardLayout::Qwertz,
|
||||
"DVORAK" => KeyboardLayout::Dvorak,
|
||||
"COLEMAK" => KeyboardLayout::Colemak,
|
||||
"BEPO" | "BÉPO" => KeyboardLayout::Bepo,
|
||||
"QWERTY_ES" => KeyboardLayout::QwertyEs,
|
||||
"QWERTY_PT" => KeyboardLayout::QwertyPt,
|
||||
"QWERTY_IT" => KeyboardLayout::QwertyIt,
|
||||
"QWERTY_NORDIC" => KeyboardLayout::QwertyNordic,
|
||||
"QWERTY_BR" => KeyboardLayout::QwertyBr,
|
||||
"QWERTY_TR" => KeyboardLayout::QwertyTr,
|
||||
"QWERTY_UK" => KeyboardLayout::QwertyUk,
|
||||
_ => KeyboardLayout::Qwerty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a `layout` and an HID key name (e.g. `"A"`, `"COMMA"`, `"SEMICOLON"`),
|
||||
/// returns the visual label for that key on the given layout, or `None` when no
|
||||
/// override exists (meaning the default / QWERTY label applies).
|
||||
///
|
||||
/// The lookup is **case-insensitive** on `hid_name`.
|
||||
pub fn remap_key_label(layout: &KeyboardLayout, hid_name: &str) -> Option<&'static str> {
|
||||
// Normalise to uppercase for matching.
|
||||
let key = hid_name.to_ascii_uppercase();
|
||||
let key = key.as_str();
|
||||
|
||||
match layout {
|
||||
// QWERTY has no overrides — it *is* the reference layout.
|
||||
KeyboardLayout::Qwerty => None,
|
||||
|
||||
KeyboardLayout::Azerty => match key {
|
||||
"COMMA" | "COMM" | "COMA" => Some(";"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some(","),
|
||||
"PERIOD" | "DOT" => Some(":"),
|
||||
"SLASH" | "SLSH" | "/" => Some("!"),
|
||||
"M" => Some(","),
|
||||
"W" => Some("Z"),
|
||||
"Z" => Some("W"),
|
||||
"Q" => Some("A"),
|
||||
"A" => Some("Q"),
|
||||
"," => Some(";"),
|
||||
"." => Some(":"),
|
||||
";" => Some("M"),
|
||||
"-" | "MINUS" | "MIN" => Some(")"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" | "[" => Some("^"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" | "]" => Some("$"),
|
||||
"BACKSLASH" | "BSLSH" | "\\" => Some("<"),
|
||||
"APOSTROPHE" | "APO" | "QUOT" | "'" => Some("\u{00f9}"), // ù
|
||||
"1" => Some("& 1"),
|
||||
"2" => Some("\u{00e9} 2 ~"), // é 2 ~
|
||||
"3" => Some("\" 3 #"),
|
||||
"4" => Some("' 4 }"),
|
||||
"5" => Some("( 5 ["),
|
||||
"6" => Some("- 6 |"),
|
||||
"7" => Some("\u{00e8} 7 `"), // è 7 `
|
||||
"8" => Some("_ 8 \\"),
|
||||
"9" => Some("\u{00e7} 9 ^"), // ç 9 ^
|
||||
"0" => Some("\u{00e0} 0 @"), // à 0 @
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Qwertz => match key {
|
||||
"Y" => Some("Z"),
|
||||
"Z" => Some("Y"),
|
||||
"MINUS" | "MIN" => Some("\u{00df}"), // ß
|
||||
"EQUAL" | "EQL" => Some("'"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00fc}"), // ü
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f6}"), // ö
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e4}"), // ä
|
||||
"GRAVE" | "GRV" => Some("^"),
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("#"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Dvorak => match key {
|
||||
"Q" => Some("'"),
|
||||
"W" => Some(","),
|
||||
"E" => Some("."),
|
||||
"R" => Some("P"),
|
||||
"T" => Some("Y"),
|
||||
"Y" => Some("F"),
|
||||
"U" => Some("G"),
|
||||
"I" => Some("C"),
|
||||
"O" => Some("R"),
|
||||
"P" => Some("L"),
|
||||
"A" => Some("A"),
|
||||
"S" => Some("O"),
|
||||
"D" => Some("E"),
|
||||
"F" => Some("U"),
|
||||
"G" => Some("I"),
|
||||
"H" => Some("D"),
|
||||
"J" => Some("H"),
|
||||
"K" => Some("T"),
|
||||
"L" => Some("N"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("S"),
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("-"),
|
||||
"Z" => Some(";"),
|
||||
"X" => Some("Q"),
|
||||
"C" => Some("J"),
|
||||
"V" => Some("K"),
|
||||
"B" => Some("X"),
|
||||
"N" => Some("B"),
|
||||
"M" => Some("M"),
|
||||
"COMMA" | "COMM" | "COMA" => Some("W"),
|
||||
"PERIOD" | "DOT" => Some("V"),
|
||||
"SLASH" | "SLSH" => Some("Z"),
|
||||
"MINUS" | "MIN" => Some("["),
|
||||
"EQUAL" | "EQL" => Some("]"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("/"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("="),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Colemak => match key {
|
||||
"E" => Some("F"),
|
||||
"R" => Some("P"),
|
||||
"T" => Some("G"),
|
||||
"Y" => Some("J"),
|
||||
"U" => Some("L"),
|
||||
"I" => Some("U"),
|
||||
"O" => Some("Y"),
|
||||
"P" => Some(";"),
|
||||
"S" => Some("R"),
|
||||
"D" => Some("S"),
|
||||
"F" => Some("T"),
|
||||
"G" => Some("D"),
|
||||
"J" => Some("N"),
|
||||
"K" => Some("E"),
|
||||
"L" => Some("I"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("O"),
|
||||
"N" => Some("K"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Bepo => match key {
|
||||
"Q" => Some("B"),
|
||||
"W" => Some("E"),
|
||||
"E" => Some("P"),
|
||||
"R" => Some("O"),
|
||||
"T" => Some("E"),
|
||||
"Y" => Some("^"),
|
||||
"U" => Some("V"),
|
||||
"I" => Some("D"),
|
||||
"O" => Some("L"),
|
||||
"P" => Some("J"),
|
||||
"A" => Some("A"),
|
||||
"S" => Some("U"),
|
||||
"D" => Some("I"),
|
||||
"F" => Some("E"),
|
||||
"G" => Some(","),
|
||||
"H" => Some("C"),
|
||||
"J" => Some("T"),
|
||||
"K" => Some("S"),
|
||||
"L" => Some("R"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("N"),
|
||||
"Z" => Some("A"),
|
||||
"X" => Some("Y"),
|
||||
"C" => Some("X"),
|
||||
"V" => Some("."),
|
||||
"B" => Some("K"),
|
||||
"N" => Some("'"),
|
||||
"M" => Some("Q"),
|
||||
"COMMA" | "COMM" | "COMA" => Some("G"),
|
||||
"PERIOD" | "DOT" => Some("H"),
|
||||
"SLASH" | "SLSH" => Some("F"),
|
||||
"1" => Some("\""),
|
||||
"2" => Some("<"),
|
||||
"3" => Some(">"),
|
||||
"4" => Some("("),
|
||||
"5" => Some(")"),
|
||||
"6" => Some("@"),
|
||||
"7" => Some("+"),
|
||||
"8" => Some("-"),
|
||||
"9" => Some("/"),
|
||||
"0" => Some("*"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyEs => match key {
|
||||
"MINUS" | "MIN" => Some("'"),
|
||||
"EQUAL" | "EQL" => Some("\u{00a1}"), // ¡
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("`"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f1}"), // ñ
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("'"),
|
||||
"GRAVE" | "GRV" => Some("\u{00ba}"), // º
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("\u{00e7}"), // ç
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyPt => match key {
|
||||
"MINUS" | "MIN" => Some("'"),
|
||||
"EQUAL" | "EQL" => Some("\u{00ab}"), // «
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("+"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("'"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00e7}"), // ç
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00ba}"), // º
|
||||
"GRAVE" | "GRV" => Some("\\"),
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("~"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyIt => match key {
|
||||
"MINUS" | "MIN" => Some("'"),
|
||||
"EQUAL" | "EQL" => Some("\u{00ec}"), // ì
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00e8}"), // è
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f2}"), // ò
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e0}"), // à
|
||||
"GRAVE" | "GRV" => Some("\\"),
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("\u{00f9}"), // ù
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyNordic => match key {
|
||||
"MINUS" | "MIN" => Some("+"),
|
||||
"EQUAL" | "EQL" => Some("'"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00e5}"), // å
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("\u{00a8}"), // ¨
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f6}"), // ö
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e4}"), // ä
|
||||
"GRAVE" | "GRV" => Some("\u{00a7}"), // §
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("'"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyBr => match key {
|
||||
"MINUS" | "MIN" => Some("-"),
|
||||
"EQUAL" | "EQL" => Some("="),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("'"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("["),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00e7}"), // ç
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("~"),
|
||||
"GRAVE" | "GRV" => Some("'"),
|
||||
"SLASH" | "SLSH" => Some(";"),
|
||||
"BACKSLASH" | "BSLSH" => Some("]"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyTr => match key {
|
||||
"MINUS" | "MIN" => Some("*"),
|
||||
"EQUAL" | "EQL" => Some("-"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{011f}"), // ğ
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("\u{00fc}"), // ü
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{015f}"), // ş
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("i"),
|
||||
"GRAVE" | "GRV" => Some("\""),
|
||||
"SLASH" | "SLSH" => Some("."),
|
||||
"BACKSLASH" | "BSLSH" => Some(","),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyUk => match key {
|
||||
"GRAVE" | "GRV" => Some("`"),
|
||||
"MINUS" | "MIN" => Some("-"),
|
||||
"EQUAL" | "EQL" => Some("="),
|
||||
"BACKSLASH" | "BSLSH" => Some("#"),
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("'"),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
900
original-src/parsers.rs
Normal file
900
original-src/parsers.rs
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
/// Parsing functions for firmware text and binary responses.
|
||||
/// Separated for testability.
|
||||
|
||||
/// Keyboard physical dimensions (must match firmware).
|
||||
pub const ROWS: usize = 5;
|
||||
pub const COLS: usize = 13;
|
||||
|
||||
/// Parse "TD0: 04,05,06,29" lines into an array of 8 tap dance slots.
|
||||
/// Each slot has 4 actions: [1-tap, 2-tap, 3-tap, hold].
|
||||
pub fn parse_td_lines(lines: &[String]) -> Vec<[u16; 4]> {
|
||||
let mut result = vec![[0u16; 4]; 8];
|
||||
|
||||
for line in lines {
|
||||
// Only process lines starting with "TD"
|
||||
let starts_with_td = line.starts_with("TD");
|
||||
if !starts_with_td {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the colon separator: "TD0: ..."
|
||||
let colon = match line.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract the index between "TD" and ":"
|
||||
let index_str = &line[2..colon];
|
||||
let idx: usize = match index_str.parse() {
|
||||
Ok(i) if i < 8 => i,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Parse the comma-separated hex values after the colon
|
||||
let after_colon = &line[colon + 1..];
|
||||
let trimmed_values = after_colon.trim();
|
||||
let split_parts = trimmed_values.split(',');
|
||||
let vals: Vec<u16> = split_parts
|
||||
.filter_map(|s| {
|
||||
let trimmed_part = s.trim();
|
||||
u16::from_str_radix(trimmed_part, 16).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// We need exactly 4 values
|
||||
let has_four_values = vals.len() == 4;
|
||||
if has_four_values {
|
||||
result[idx] = [vals[0], vals[1], vals[2], vals[3]];
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse KO (Key Override) lines into arrays of [trigger, mod, result, res_mod].
|
||||
/// Format: "KO0: trigger=2A mod=02 -> result=4C resmod=00"
|
||||
pub fn parse_ko_lines(lines: &[String]) -> Vec<[u8; 4]> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
// Only process lines starting with "KO"
|
||||
let starts_with_ko = line.starts_with("KO");
|
||||
if !starts_with_ko {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Helper: extract hex value after a keyword like "trigger="
|
||||
let parse_hex = |key: &str| -> u8 {
|
||||
let key_position = line.find(key);
|
||||
|
||||
let after_key = match key_position {
|
||||
Some(i) => {
|
||||
let rest = &line[i + key.len()..];
|
||||
let first_token = rest.split_whitespace().next();
|
||||
first_token
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let parsed_value = match after_key {
|
||||
Some(s) => {
|
||||
let without_prefix = s.trim_start_matches("0x");
|
||||
u8::from_str_radix(without_prefix, 16).ok()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
parsed_value.unwrap_or(0)
|
||||
};
|
||||
|
||||
let trigger = parse_hex("trigger=");
|
||||
let modifier = parse_hex("mod=");
|
||||
let result_key = parse_hex("result=");
|
||||
let result_mod = parse_hex("resmod=");
|
||||
|
||||
result.push([trigger, modifier, result_key, result_mod]);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse heatmap lines (KEYSTATS? response).
|
||||
/// Format: "R0: 100 50 30 20 10 5 0 15 25 35 45 55 65"
|
||||
/// Returns (data[5][13], max_value).
|
||||
pub fn parse_heatmap_lines(lines: &[String]) -> (Vec<Vec<u32>>, u32) {
|
||||
let mut data: Vec<Vec<u32>> = vec![vec![0u32; COLS]; ROWS];
|
||||
let mut max = 0u32;
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Only process lines starting with "R"
|
||||
if !trimmed.starts_with('R') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the colon
|
||||
let colon = match trimmed.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract row number between "R" and ":"
|
||||
let row_str = &trimmed[1..colon];
|
||||
let row: usize = match row_str.parse() {
|
||||
Ok(r) if r < ROWS => r,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Parse space-separated values after the colon
|
||||
let values_str = &trimmed[colon + 1..];
|
||||
for (col, token) in values_str.split_whitespace().enumerate() {
|
||||
if col >= COLS {
|
||||
break;
|
||||
}
|
||||
let count: u32 = token.parse().unwrap_or(0);
|
||||
data[row][col] = count;
|
||||
if count > max {
|
||||
max = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(data, max)
|
||||
}
|
||||
|
||||
/// Parsed combo: [index, row1, col1, row2, col2, result_keycode]
|
||||
#[derive(Clone)]
|
||||
pub struct ComboEntry {
|
||||
pub index: u8,
|
||||
pub r1: u8,
|
||||
pub c1: u8,
|
||||
pub r2: u8,
|
||||
pub c2: u8,
|
||||
pub result: u16,
|
||||
}
|
||||
|
||||
/// Parse "COMBO0: r3c3+r3c4=29" lines.
|
||||
pub fn parse_combo_lines(lines: &[String]) -> Vec<ComboEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let starts_with_combo = line.starts_with("COMBO");
|
||||
if !starts_with_combo {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find colon: "COMBO0: ..."
|
||||
let colon = match line.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Index between "COMBO" and ":"
|
||||
let index_str = &line[5..colon];
|
||||
let index: u8 = match index_str.parse() {
|
||||
Ok(i) => i,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// After colon: "r3c3+r3c4=29"
|
||||
let rest = line[colon + 1..].trim();
|
||||
|
||||
// Split by "="
|
||||
let eq_parts: Vec<&str> = rest.split('=').collect();
|
||||
let has_two_parts = eq_parts.len() == 2;
|
||||
if !has_two_parts {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split left side by "+"
|
||||
let key_parts: Vec<&str> = eq_parts[0].split('+').collect();
|
||||
let has_two_keys = key_parts.len() == 2;
|
||||
if !has_two_keys {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse "r3c4" format
|
||||
let pos1 = parse_rc(key_parts[0].trim());
|
||||
let pos2 = parse_rc(key_parts[1].trim());
|
||||
|
||||
let (r1, c1) = match pos1 {
|
||||
Some(rc) => rc,
|
||||
None => continue,
|
||||
};
|
||||
let (r2, c2) = match pos2 {
|
||||
Some(rc) => rc,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Parse result keycode (hex)
|
||||
let result_str = eq_parts[1].trim();
|
||||
let result_code: u16 = match u16::from_str_radix(result_str, 16) {
|
||||
Ok(v) => v,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
result.push(ComboEntry {
|
||||
index,
|
||||
r1,
|
||||
c1,
|
||||
r2,
|
||||
c2,
|
||||
result: result_code,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse "r3c4" into (row, col).
|
||||
fn parse_rc(s: &str) -> Option<(u8, u8)> {
|
||||
let lower = s.to_lowercase();
|
||||
|
||||
let r_pos = lower.find('r');
|
||||
let c_pos = lower.find('c');
|
||||
|
||||
let r_idx = match r_pos {
|
||||
Some(i) => i,
|
||||
None => return None,
|
||||
};
|
||||
let c_idx = match c_pos {
|
||||
Some(i) => i,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let row_str = &lower[r_idx + 1..c_idx];
|
||||
let col_str = &lower[c_idx + 1..];
|
||||
|
||||
let row: u8 = match row_str.parse() {
|
||||
Ok(v) => v,
|
||||
_ => return None,
|
||||
};
|
||||
let col: u8 = match col_str.parse() {
|
||||
Ok(v) => v,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some((row, col))
|
||||
}
|
||||
|
||||
/// Parsed leader sequence entry.
|
||||
#[derive(Clone)]
|
||||
pub struct LeaderEntry {
|
||||
pub index: u8,
|
||||
pub sequence: Vec<u8>, // HID keycodes
|
||||
pub result: u8,
|
||||
pub result_mod: u8,
|
||||
}
|
||||
|
||||
/// Parse "LEADER0: 04,->29+00" lines.
|
||||
pub fn parse_leader_lines(lines: &[String]) -> Vec<LeaderEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let starts_with_leader = line.starts_with("LEADER");
|
||||
if !starts_with_leader {
|
||||
continue;
|
||||
}
|
||||
|
||||
let colon = match line.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let index_str = &line[6..colon];
|
||||
let index: u8 = match index_str.parse() {
|
||||
Ok(i) => i,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// After colon: "04,->29+00"
|
||||
let rest = line[colon + 1..].trim();
|
||||
|
||||
// Split by "->"
|
||||
let arrow_parts: Vec<&str> = rest.split("->").collect();
|
||||
let has_two_parts = arrow_parts.len() == 2;
|
||||
if !has_two_parts {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sequence: comma-separated hex keycodes (trailing comma OK)
|
||||
let seq_str = arrow_parts[0].trim().trim_end_matches(',');
|
||||
let sequence: Vec<u8> = seq_str
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let trimmed = s.trim();
|
||||
u8::from_str_radix(trimmed, 16).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Result: "29+00" = keycode + modifier
|
||||
let result_parts: Vec<&str> = arrow_parts[1].trim().split('+').collect();
|
||||
let has_result = result_parts.len() == 2;
|
||||
if !has_result {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result_key = match u8::from_str_radix(result_parts[0].trim(), 16) {
|
||||
Ok(v) => v,
|
||||
_ => continue,
|
||||
};
|
||||
let result_mod = match u8::from_str_radix(result_parts[1].trim(), 16) {
|
||||
Ok(v) => v,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
result.push(LeaderEntry {
|
||||
index,
|
||||
sequence,
|
||||
result: result_key,
|
||||
result_mod,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// A single macro step: keycode + modifier, or delay.
|
||||
#[derive(Clone)]
|
||||
pub struct MacroStep {
|
||||
pub keycode: u8,
|
||||
pub modifier: u8,
|
||||
}
|
||||
|
||||
impl MacroStep {
|
||||
/// Returns true if this step is a delay (keycode 0xFF).
|
||||
pub fn is_delay(&self) -> bool {
|
||||
self.keycode == 0xFF
|
||||
}
|
||||
|
||||
/// Delay in milliseconds (modifier * 10).
|
||||
pub fn delay_ms(&self) -> u32 {
|
||||
self.modifier as u32 * 10
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed macro entry.
|
||||
#[derive(Clone)]
|
||||
pub struct MacroEntry {
|
||||
pub slot: u8,
|
||||
pub name: String,
|
||||
pub steps: Vec<MacroStep>,
|
||||
}
|
||||
|
||||
/// Parse MACROS? text response.
|
||||
/// Lines can be like:
|
||||
/// "MACRO 0: CopyPaste [06:01,FF:0A,19:01]"
|
||||
/// "M0: name=CopyPaste steps=06:01,FF:0A,19:01"
|
||||
/// or just raw text lines
|
||||
pub fn parse_macro_lines(lines: &[String]) -> Vec<MacroEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
// Try format: "MACRO 0: name [steps]" or "M0: ..."
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip empty or header lines
|
||||
let is_empty = trimmed.is_empty();
|
||||
if is_empty {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to find slot number
|
||||
let has_macro_prefix = trimmed.starts_with("MACRO") || trimmed.starts_with("M");
|
||||
if !has_macro_prefix {
|
||||
continue;
|
||||
}
|
||||
|
||||
let colon = match trimmed.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract slot number from prefix
|
||||
let prefix_end = trimmed[..colon].trim();
|
||||
let digits_start = prefix_end
|
||||
.find(|c: char| c.is_ascii_digit())
|
||||
.unwrap_or(prefix_end.len());
|
||||
let slot_str = &prefix_end[digits_start..];
|
||||
let slot: u8 = match slot_str.trim().parse() {
|
||||
Ok(s) => s,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let after_colon = trimmed[colon + 1..].trim();
|
||||
|
||||
// Try to parse name and steps from brackets: "CopyPaste [06:01,FF:0A,19:01]"
|
||||
let bracket_start = after_colon.find('[');
|
||||
let bracket_end = after_colon.find(']');
|
||||
|
||||
let (name, steps_str) = match (bracket_start, bracket_end) {
|
||||
(Some(bs), Some(be)) => {
|
||||
let name_part = after_colon[..bs].trim().to_string();
|
||||
let steps_part = &after_colon[bs + 1..be];
|
||||
(name_part, steps_part.to_string())
|
||||
}
|
||||
_ => {
|
||||
// Try "name=X steps=Y" format
|
||||
let name_eq = after_colon.find("name=");
|
||||
let steps_eq = after_colon.find("steps=");
|
||||
match (name_eq, steps_eq) {
|
||||
(Some(ni), Some(si)) => {
|
||||
let name_start = ni + 5;
|
||||
let name_end = si;
|
||||
let n = after_colon[name_start..name_end].trim().to_string();
|
||||
let s = after_colon[si + 6..].trim().to_string();
|
||||
(n, s)
|
||||
}
|
||||
_ => {
|
||||
// Just use the whole thing as name, no steps
|
||||
(after_colon.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse steps: "06:01,FF:0A,19:01"
|
||||
let mut steps = Vec::new();
|
||||
let has_steps = !steps_str.is_empty();
|
||||
if has_steps {
|
||||
let step_parts = steps_str.split(',');
|
||||
for part in step_parts {
|
||||
let step_trimmed = part.trim();
|
||||
let kv: Vec<&str> = step_trimmed.split(':').collect();
|
||||
let has_two = kv.len() == 2;
|
||||
if !has_two {
|
||||
continue;
|
||||
}
|
||||
let key_byte = u8::from_str_radix(kv[0].trim(), 16).unwrap_or(0);
|
||||
let mod_byte = u8::from_str_radix(kv[1].trim(), 16).unwrap_or(0);
|
||||
steps.push(MacroStep {
|
||||
keycode: key_byte,
|
||||
modifier: mod_byte,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.push(MacroEntry {
|
||||
slot,
|
||||
name,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary payload parsers (protocol v2)
|
||||
// ---------------------------------------------------------------------------
|
||||
// These functions parse the *payload* bytes extracted from a KR response frame.
|
||||
// They produce the same data types as the text parsers above.
|
||||
|
||||
/// Parse TD_LIST (0x51) binary payload.
|
||||
/// Format: [count:u8] then count entries of [idx:u8][a1:u8][a2:u8][a3:u8][a4:u8].
|
||||
/// Returns Vec<[u16; 4]> with 8 slots (same shape as parse_td_lines).
|
||||
pub fn parse_td_binary(payload: &[u8]) -> Vec<[u16; 4]> {
|
||||
let mut result = vec![[0u16; 4]; 8];
|
||||
|
||||
// Need at least 1 byte for count
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let entry_size = 5; // idx(1) + actions(4)
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
// Bounds check: need 5 bytes for this entry
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_bytes = remaining >= entry_size;
|
||||
if !enough_bytes {
|
||||
break;
|
||||
}
|
||||
|
||||
let idx = payload[offset] as usize;
|
||||
let action1 = payload[offset + 1] as u16;
|
||||
let action2 = payload[offset + 2] as u16;
|
||||
let action3 = payload[offset + 3] as u16;
|
||||
let action4 = payload[offset + 4] as u16;
|
||||
|
||||
let valid_index = idx < 8;
|
||||
if valid_index {
|
||||
result[idx] = [action1, action2, action3, action4];
|
||||
}
|
||||
|
||||
offset += entry_size;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse COMBO_LIST (0x61) binary payload.
|
||||
/// Format: [count:u8] then count entries of [idx:u8][r1:u8][c1:u8][r2:u8][c2:u8][result:u8].
|
||||
pub fn parse_combo_binary(payload: &[u8]) -> Vec<ComboEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let entry_size = 6; // idx + r1 + c1 + r2 + c2 + result
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_bytes = remaining >= entry_size;
|
||||
if !enough_bytes {
|
||||
break;
|
||||
}
|
||||
|
||||
let index = payload[offset];
|
||||
let r1 = payload[offset + 1];
|
||||
let c1 = payload[offset + 2];
|
||||
let r2 = payload[offset + 3];
|
||||
let c2 = payload[offset + 4];
|
||||
let result_code = payload[offset + 5] as u16;
|
||||
|
||||
result.push(ComboEntry {
|
||||
index,
|
||||
r1,
|
||||
c1,
|
||||
r2,
|
||||
c2,
|
||||
result: result_code,
|
||||
});
|
||||
|
||||
offset += entry_size;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse LEADER_LIST (0x71) binary payload.
|
||||
/// Format: [count:u8] then per entry: [idx:u8][seq_len:u8][seq: seq_len bytes][result:u8][result_mod:u8].
|
||||
pub fn parse_leader_binary(payload: &[u8]) -> Vec<LeaderEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
// Need at least idx(1) + seq_len(1)
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_for_header = remaining >= 2;
|
||||
if !enough_for_header {
|
||||
break;
|
||||
}
|
||||
|
||||
let index = payload[offset];
|
||||
let seq_len = payload[offset + 1] as usize;
|
||||
offset += 2;
|
||||
|
||||
// Need seq_len bytes for sequence + 2 bytes for result+result_mod
|
||||
let remaining_after_header = payload.len().saturating_sub(offset);
|
||||
let enough_for_body = remaining_after_header >= seq_len + 2;
|
||||
if !enough_for_body {
|
||||
break;
|
||||
}
|
||||
|
||||
let sequence = payload[offset..offset + seq_len].to_vec();
|
||||
offset += seq_len;
|
||||
|
||||
let result_key = payload[offset];
|
||||
let result_mod = payload[offset + 1];
|
||||
offset += 2;
|
||||
|
||||
result.push(LeaderEntry {
|
||||
index,
|
||||
sequence,
|
||||
result: result_key,
|
||||
result_mod,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse KO_LIST (0x92) binary payload.
|
||||
/// Format: [count:u8] then count entries of [idx:u8][trigger_key:u8][trigger_mod:u8][result_key:u8][result_mod:u8].
|
||||
/// Returns Vec<[u8; 4]> = [trigger_key, trigger_mod, result_key, result_mod] (same as parse_ko_lines).
|
||||
pub fn parse_ko_binary(payload: &[u8]) -> Vec<[u8; 4]> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let entry_size = 5; // idx + trigger_key + trigger_mod + result_key + result_mod
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_bytes = remaining >= entry_size;
|
||||
if !enough_bytes {
|
||||
break;
|
||||
}
|
||||
|
||||
// idx is payload[offset], but we skip it (not stored in output)
|
||||
let trigger_key = payload[offset + 1];
|
||||
let trigger_mod = payload[offset + 2];
|
||||
let result_key = payload[offset + 3];
|
||||
let result_mod = payload[offset + 4];
|
||||
|
||||
result.push([trigger_key, trigger_mod, result_key, result_mod]);
|
||||
|
||||
offset += entry_size;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse BT_QUERY (0x80) binary payload.
|
||||
/// Format: [active_slot:u8][initialized:u8][connected:u8][pairing:u8]
|
||||
/// then 3 slot entries: [slot_idx:u8][valid:u8][addr:6 bytes][name_len:u8][name: name_len bytes]
|
||||
/// Returns Vec<String> of text lines compatible with the UI (same shape as legacy text parsing).
|
||||
pub fn parse_bt_binary(payload: &[u8]) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Need at least 4 bytes for the global state header
|
||||
let enough_for_header = payload.len() >= 4;
|
||||
if !enough_for_header {
|
||||
return lines;
|
||||
}
|
||||
|
||||
let active_slot = payload[0];
|
||||
let initialized = payload[1];
|
||||
let connected = payload[2];
|
||||
let pairing = payload[3];
|
||||
|
||||
let status_line = format!(
|
||||
"BT: slot={} init={} conn={} pairing={}",
|
||||
active_slot, initialized, connected, pairing
|
||||
);
|
||||
lines.push(status_line);
|
||||
|
||||
let mut offset = 4;
|
||||
let slot_count = 3;
|
||||
|
||||
for _ in 0..slot_count {
|
||||
// Each slot: slot_idx(1) + valid(1) + addr(6) + name_len(1) = 9 bytes minimum
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_for_slot_header = remaining >= 9;
|
||||
if !enough_for_slot_header {
|
||||
break;
|
||||
}
|
||||
|
||||
let slot_idx = payload[offset];
|
||||
let valid = payload[offset + 1];
|
||||
let addr_bytes = &payload[offset + 2..offset + 8];
|
||||
let name_len = payload[offset + 8] as usize;
|
||||
offset += 9;
|
||||
|
||||
// Read the name string
|
||||
let remaining_for_name = payload.len().saturating_sub(offset);
|
||||
let enough_for_name = remaining_for_name >= name_len;
|
||||
if !enough_for_name {
|
||||
break;
|
||||
}
|
||||
|
||||
let name_bytes = &payload[offset..offset + name_len];
|
||||
let name = String::from_utf8_lossy(name_bytes).to_string();
|
||||
offset += name_len;
|
||||
|
||||
// Format address as "XX:XX:XX:XX:XX:XX"
|
||||
let addr_str = format!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
addr_bytes[0], addr_bytes[1], addr_bytes[2],
|
||||
addr_bytes[3], addr_bytes[4], addr_bytes[5]
|
||||
);
|
||||
|
||||
let slot_line = format!(
|
||||
"BT slot {}: valid={} addr={} name={}",
|
||||
slot_idx, valid, addr_str, name
|
||||
);
|
||||
lines.push(slot_line);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Parse TAMA_QUERY (0xA0) binary payload (22 bytes fixed).
|
||||
/// Format: [enabled:u8][state:u8][hunger:u16 LE][happiness:u16 LE][energy:u16 LE]
|
||||
/// [health:u16 LE][level:u16 LE][xp:u16 LE][total_keys:u32 LE][max_kpm:u32 LE]
|
||||
/// Returns Vec<String> with one summary line.
|
||||
pub fn parse_tama_binary(payload: &[u8]) -> Vec<String> {
|
||||
let expected_size = 22;
|
||||
let enough_bytes = payload.len() >= expected_size;
|
||||
if !enough_bytes {
|
||||
return vec!["TAMA: invalid payload".to_string()];
|
||||
}
|
||||
|
||||
let enabled = payload[0];
|
||||
let _state = payload[1];
|
||||
|
||||
let hunger = u16::from_le_bytes([payload[2], payload[3]]);
|
||||
let happiness = u16::from_le_bytes([payload[4], payload[5]]);
|
||||
let energy = u16::from_le_bytes([payload[6], payload[7]]);
|
||||
let health = u16::from_le_bytes([payload[8], payload[9]]);
|
||||
let level = u16::from_le_bytes([payload[10], payload[11]]);
|
||||
let _xp = u16::from_le_bytes([payload[12], payload[13]]);
|
||||
let total_keys = u32::from_le_bytes([payload[14], payload[15], payload[16], payload[17]]);
|
||||
let _max_kpm = u32::from_le_bytes([payload[18], payload[19], payload[20], payload[21]]);
|
||||
|
||||
let line = format!(
|
||||
"TAMA: Lv{} hunger={} happy={} energy={} health={} keys={} enabled={}",
|
||||
level, hunger, happiness, energy, health, total_keys, enabled
|
||||
);
|
||||
|
||||
vec![line]
|
||||
}
|
||||
|
||||
/// Parse WPM_QUERY (0x93) binary payload (2 bytes fixed).
|
||||
/// Format: [wpm:u16 LE]
|
||||
pub fn parse_wpm_binary(payload: &[u8]) -> String {
|
||||
let enough_bytes = payload.len() >= 2;
|
||||
if !enough_bytes {
|
||||
return "WPM: 0".to_string();
|
||||
}
|
||||
|
||||
let wpm = u16::from_le_bytes([payload[0], payload[1]]);
|
||||
|
||||
format!("WPM: {}", wpm)
|
||||
}
|
||||
|
||||
/// Parse LIST_MACROS (0x30) binary payload.
|
||||
/// Format: [count:u8] then per entry:
|
||||
/// [idx:u8][keycode:u16 LE][name_len:u8][name: name_len bytes]
|
||||
/// [keys_len:u8][keys: keys_len bytes][step_count:u8][{kc:u8,mod:u8}... step_count*2 bytes]
|
||||
pub fn parse_macros_binary(payload: &[u8]) -> Vec<MacroEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
// Need at least: idx(1) + keycode(2) + name_len(1) = 4
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_for_prefix = remaining >= 4;
|
||||
if !enough_for_prefix {
|
||||
break;
|
||||
}
|
||||
|
||||
let slot = payload[offset];
|
||||
// keycode is stored but not used in MacroEntry (it's the trigger keycode)
|
||||
let _keycode = u16::from_le_bytes([payload[offset + 1], payload[offset + 2]]);
|
||||
let name_len = payload[offset + 3] as usize;
|
||||
offset += 4;
|
||||
|
||||
// Read name
|
||||
let remaining_for_name = payload.len().saturating_sub(offset);
|
||||
let enough_for_name = remaining_for_name >= name_len;
|
||||
if !enough_for_name {
|
||||
break;
|
||||
}
|
||||
|
||||
let name_bytes = &payload[offset..offset + name_len];
|
||||
let name = String::from_utf8_lossy(name_bytes).to_string();
|
||||
offset += name_len;
|
||||
|
||||
// Read keys_len + keys (raw key bytes, skipped for MacroEntry)
|
||||
let remaining_for_keys_len = payload.len().saturating_sub(offset);
|
||||
let enough_for_keys_len = remaining_for_keys_len >= 1;
|
||||
if !enough_for_keys_len {
|
||||
break;
|
||||
}
|
||||
|
||||
let keys_len = payload[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
let remaining_for_keys = payload.len().saturating_sub(offset);
|
||||
let enough_for_keys = remaining_for_keys >= keys_len;
|
||||
if !enough_for_keys {
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip the raw keys bytes
|
||||
offset += keys_len;
|
||||
|
||||
// Read step_count + steps
|
||||
let remaining_for_step_count = payload.len().saturating_sub(offset);
|
||||
let enough_for_step_count = remaining_for_step_count >= 1;
|
||||
if !enough_for_step_count {
|
||||
break;
|
||||
}
|
||||
|
||||
let step_count = payload[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
let steps_byte_size = step_count * 2;
|
||||
let remaining_for_steps = payload.len().saturating_sub(offset);
|
||||
let enough_for_steps = remaining_for_steps >= steps_byte_size;
|
||||
if !enough_for_steps {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut steps = Vec::with_capacity(step_count);
|
||||
for i in 0..step_count {
|
||||
let step_offset = offset + i * 2;
|
||||
let kc = payload[step_offset];
|
||||
let md = payload[step_offset + 1];
|
||||
steps.push(MacroStep {
|
||||
keycode: kc,
|
||||
modifier: md,
|
||||
});
|
||||
}
|
||||
offset += steps_byte_size;
|
||||
|
||||
result.push(MacroEntry {
|
||||
slot,
|
||||
name,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse KEYSTATS_BIN (0x40) binary payload.
|
||||
/// Format: [rows:u8][cols:u8][counts: rows*cols * u32 LE]
|
||||
/// Returns (heatmap_data, max_value) — same shape as parse_heatmap_lines.
|
||||
pub fn parse_keystats_binary(payload: &[u8]) -> (Vec<Vec<u32>>, u32) {
|
||||
// Need at least 2 bytes for rows and cols
|
||||
let enough_for_header = payload.len() >= 2;
|
||||
if !enough_for_header {
|
||||
return (vec![], 0);
|
||||
}
|
||||
|
||||
let rows = payload[0] as usize;
|
||||
let cols = payload[1] as usize;
|
||||
let total_cells = rows * cols;
|
||||
let data_byte_size = total_cells * 4; // each count is u32 LE
|
||||
|
||||
let remaining = payload.len().saturating_sub(2);
|
||||
let enough_for_data = remaining >= data_byte_size;
|
||||
if !enough_for_data {
|
||||
return (vec![], 0);
|
||||
}
|
||||
|
||||
let mut data: Vec<Vec<u32>> = vec![vec![0u32; cols]; rows];
|
||||
let mut max_value = 0u32;
|
||||
let mut offset = 2;
|
||||
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let count = u32::from_le_bytes([
|
||||
payload[offset],
|
||||
payload[offset + 1],
|
||||
payload[offset + 2],
|
||||
payload[offset + 3],
|
||||
]);
|
||||
data[row][col] = count;
|
||||
|
||||
let is_new_max = count > max_value;
|
||||
if is_new_max {
|
||||
max_value = count;
|
||||
}
|
||||
|
||||
offset += 4;
|
||||
}
|
||||
}
|
||||
|
||||
(data, max_value)
|
||||
}
|
||||
65
original-src/protocol.rs
Normal file
65
original-src/protocol.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#![allow(dead_code)]
|
||||
/// CDC protocol command helpers for KaSe keyboard firmware.
|
||||
|
||||
// Text-based query commands
|
||||
pub const CMD_TAP_DANCE: &str = "TD?";
|
||||
pub const CMD_COMBOS: &str = "COMBO?";
|
||||
pub const CMD_LEADER: &str = "LEADER?";
|
||||
pub const CMD_KEY_OVERRIDE: &str = "KO?";
|
||||
pub const CMD_BT_STATUS: &str = "BT?";
|
||||
pub const CMD_WPM: &str = "WPM?";
|
||||
pub const CMD_TAMA: &str = "TAMA?";
|
||||
pub const CMD_MACROS_TEXT: &str = "MACROS?";
|
||||
pub const CMD_FEATURES: &str = "FEATURES?";
|
||||
pub const CMD_KEYSTATS: &str = "KEYSTATS?";
|
||||
pub const CMD_BIGRAMS: &str = "BIGRAMS?";
|
||||
|
||||
pub fn cmd_set_key(layer: u8, row: u8, col: u8, keycode: u16) -> String {
|
||||
format!("SETKEY {},{},{},{:04X}", layer, row, col, keycode)
|
||||
}
|
||||
|
||||
pub fn cmd_set_layer_name(layer: u8, name: &str) -> String {
|
||||
format!("LAYOUTNAME{}:{}", layer, name)
|
||||
}
|
||||
|
||||
pub fn cmd_bt_switch(slot: u8) -> String {
|
||||
format!("BT SWITCH {}", slot)
|
||||
}
|
||||
|
||||
pub fn cmd_trilayer(l1: u8, l2: u8, l3: u8) -> String {
|
||||
format!("TRILAYER {},{},{}", l1, l2, l3)
|
||||
}
|
||||
|
||||
pub fn cmd_macroseq(slot: u8, name: &str, steps: &str) -> String {
|
||||
format!("MACROSEQ {};{};{}", slot, name, steps)
|
||||
}
|
||||
|
||||
pub fn cmd_macro_del(slot: u8) -> String {
|
||||
format!("MACRODEL {}", slot)
|
||||
}
|
||||
|
||||
pub fn cmd_comboset(index: u8, r1: u8, c1: u8, r2: u8, c2: u8, result: u8) -> String {
|
||||
format!("COMBOSET {};{},{},{},{},{:02X}", index, r1, c1, r2, c2, result)
|
||||
}
|
||||
|
||||
pub fn cmd_combodel(index: u8) -> String {
|
||||
format!("COMBODEL {}", index)
|
||||
}
|
||||
|
||||
pub fn cmd_koset(index: u8, trig_key: u8, trig_mod: u8, res_key: u8, res_mod: u8) -> String {
|
||||
format!("KOSET {};{:02X},{:02X},{:02X},{:02X}", index, trig_key, trig_mod, res_key, res_mod)
|
||||
}
|
||||
|
||||
pub fn cmd_kodel(index: u8) -> String {
|
||||
format!("KODEL {}", index)
|
||||
}
|
||||
|
||||
pub fn cmd_leaderset(index: u8, sequence: &[u8], result: u8, result_mod: u8) -> String {
|
||||
let seq_hex: Vec<String> = sequence.iter().map(|k| format!("{:02X}", k)).collect();
|
||||
let seq_str = seq_hex.join(",");
|
||||
format!("LEADERSET {};{};{:02X},{:02X}", index, seq_str, result, result_mod)
|
||||
}
|
||||
|
||||
pub fn cmd_leaderdel(index: u8) -> String {
|
||||
format!("LEADERDEL {}", index)
|
||||
}
|
||||
15
original-src/serial/mod.rs
Normal file
15
original-src/serial/mod.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/// Serial communication module.
|
||||
/// Dispatches to native (serialport crate) or web (WebSerial API)
|
||||
/// depending on the target architecture.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::*;
|
||||
559
original-src/serial/native.rs
Normal file
559
original-src/serial/native.rs
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
use serialport::SerialPort;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::binary_protocol::{self as bp, KrResponse};
|
||||
use crate::parsers::{ROWS, COLS};
|
||||
|
||||
const BAUD_RATE: u32 = 115200;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 300;
|
||||
const QUERY_TIMEOUT_MS: u64 = 800;
|
||||
const BINARY_READ_TIMEOUT_MS: u64 = 1500;
|
||||
const LEGACY_BINARY_SETTLE_MS: u64 = 50;
|
||||
const BINARY_SETTLE_MS: u64 = 30;
|
||||
const JSON_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
pub struct SerialManager {
|
||||
port: Option<Box<dyn SerialPort>>,
|
||||
pub port_name: String,
|
||||
pub connected: bool,
|
||||
pub v2: bool, // true if firmware supports binary protocol v2
|
||||
}
|
||||
|
||||
impl SerialManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
port: None,
|
||||
port_name: String::new(),
|
||||
connected: false,
|
||||
v2: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn list_ports() -> Vec<String> {
|
||||
let available = serialport::available_ports();
|
||||
let ports = available.unwrap_or_default();
|
||||
let port_iter = ports.into_iter();
|
||||
let name_iter = port_iter.map(|p| p.port_name);
|
||||
let names: Vec<String> = name_iter.collect();
|
||||
names
|
||||
}
|
||||
|
||||
pub fn connect(&mut self, port_name: &str) -> Result<(), String> {
|
||||
let builder = serialport::new(port_name, BAUD_RATE);
|
||||
let builder_with_timeout = builder.timeout(Duration::from_millis(CONNECT_TIMEOUT_MS));
|
||||
let open_result = builder_with_timeout.open();
|
||||
let port = open_result.map_err(|e| format!("Failed to open {}: {}", port_name, e))?;
|
||||
|
||||
self.port = Some(port);
|
||||
self.port_name = port_name.to_string();
|
||||
self.connected = true;
|
||||
self.v2 = false;
|
||||
|
||||
// Detect v2: try PING
|
||||
if let Some(p) = self.port.as_mut() {
|
||||
let _ = p.clear(serialport::ClearBuffer::All);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
|
||||
|
||||
let ping_result = self.send_binary(bp::cmd::PING, &[]);
|
||||
if ping_result.is_ok() {
|
||||
self.v2 = true;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn auto_connect(&mut self) -> Result<String, String> {
|
||||
let port_name = Self::find_kase_port()?;
|
||||
self.connect(&port_name)?;
|
||||
Ok(port_name)
|
||||
}
|
||||
|
||||
pub fn find_kase_port() -> Result<String, String> {
|
||||
const TARGET_VID: u16 = 0xCAFE;
|
||||
const TARGET_PID: u16 = 0x4001;
|
||||
|
||||
let available = serialport::available_ports();
|
||||
let ports = available.unwrap_or_default();
|
||||
if ports.is_empty() {
|
||||
return Err("No serial ports found".into());
|
||||
}
|
||||
|
||||
// First pass: check USB VID/PID and product name
|
||||
for port in &ports {
|
||||
let port_type = &port.port_type;
|
||||
match port_type {
|
||||
serialport::SerialPortType::UsbPort(usb) => {
|
||||
let vid_matches = usb.vid == TARGET_VID;
|
||||
let pid_matches = usb.pid == TARGET_PID;
|
||||
if vid_matches && pid_matches {
|
||||
return Ok(port.port_name.clone());
|
||||
}
|
||||
|
||||
match &usb.product {
|
||||
Some(product) => {
|
||||
let is_kase = product.contains("KaSe");
|
||||
let is_kesp = product.contains("KeSp");
|
||||
if is_kase || is_kesp {
|
||||
return Ok(port.port_name.clone());
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass (Linux only): check udevadm info
|
||||
#[cfg(target_os = "linux")]
|
||||
for port in &ports {
|
||||
let udevadm_result = std::process::Command::new("udevadm")
|
||||
.args(["info", "-n", &port.port_name])
|
||||
.output();
|
||||
|
||||
match udevadm_result {
|
||||
Ok(output) => {
|
||||
let stdout_bytes = &output.stdout;
|
||||
let text = String::from_utf8_lossy(stdout_bytes);
|
||||
let has_kase = text.contains("KaSe");
|
||||
let has_kesp = text.contains("KeSp");
|
||||
if has_kase || has_kesp {
|
||||
return Ok(port.port_name.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let scanned_count = ports.len();
|
||||
Err(format!("No KaSe keyboard found ({} port(s) scanned)", scanned_count))
|
||||
}
|
||||
|
||||
pub fn port_mut(&mut self) -> Option<&mut Box<dyn SerialPort>> {
|
||||
self.port.as_mut()
|
||||
}
|
||||
|
||||
pub fn disconnect(&mut self) {
|
||||
self.port = None;
|
||||
self.port_name.clear();
|
||||
self.connected = false;
|
||||
self.v2 = false;
|
||||
}
|
||||
|
||||
// ==================== LOW-LEVEL: ASCII LEGACY ====================
|
||||
|
||||
pub fn send_command(&mut self, cmd: &str) -> Result<(), String> {
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let data = format!("{}\r\n", cmd);
|
||||
let bytes = data.as_bytes();
|
||||
|
||||
let write_result = port.write_all(bytes);
|
||||
write_result.map_err(|e| format!("Write: {}", e))?;
|
||||
|
||||
let flush_result = port.flush();
|
||||
flush_result.map_err(|e| format!("Flush: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn query_command(&mut self, cmd: &str) -> Result<Vec<String>, String> {
|
||||
self.send_command(cmd)?;
|
||||
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let cloned_port = port.try_clone();
|
||||
let port_clone = cloned_port.map_err(|e| e.to_string())?;
|
||||
let mut reader = BufReader::new(port_clone);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let start = Instant::now();
|
||||
let max_wait = Duration::from_millis(QUERY_TIMEOUT_MS);
|
||||
|
||||
loop {
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed > max_wait {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut line = String::new();
|
||||
let read_result = reader.read_line(&mut line);
|
||||
|
||||
match read_result {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim().to_string();
|
||||
let is_terminal = trimmed == "OK" || trimmed == "ERROR";
|
||||
if is_terminal {
|
||||
break;
|
||||
}
|
||||
let is_not_empty = !trimmed.is_empty();
|
||||
if is_not_empty {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn read_raw(&mut self, timeout_ms: u64) -> Result<Vec<u8>, String> {
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut result = Vec::new();
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
let read_result = port.read(&mut buf);
|
||||
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
let received_bytes = &buf[..n];
|
||||
result.extend_from_slice(received_bytes);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
_ => {
|
||||
let got_something = !result.is_empty();
|
||||
if got_something {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Legacy C> binary protocol
|
||||
fn query_legacy_binary(&mut self, cmd: &str) -> Result<(u8, Vec<u8>), String> {
|
||||
self.send_command(cmd)?;
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
|
||||
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
|
||||
|
||||
// Look for the "C>" header in the raw bytes
|
||||
let mut windows = raw.windows(2);
|
||||
let header_search = windows.position(|w| w == b"C>");
|
||||
let pos = header_search.ok_or("No C> header found")?;
|
||||
|
||||
let min_packet_size = pos + 5;
|
||||
if raw.len() < min_packet_size {
|
||||
return Err("Packet too short".into());
|
||||
}
|
||||
|
||||
let cmd_type = raw[pos + 2];
|
||||
let low_byte = raw[pos + 3] as u16;
|
||||
let high_byte = (raw[pos + 4] as u16) << 8;
|
||||
let data_len = low_byte | high_byte;
|
||||
|
||||
let data_start = pos + 5;
|
||||
let data_end = data_start.checked_add(data_len as usize)
|
||||
.ok_or("Data length overflow")?;
|
||||
|
||||
if raw.len() < data_end {
|
||||
return Err(format!("Incomplete: need {}, got {}", data_end, raw.len()));
|
||||
}
|
||||
|
||||
let payload = raw[data_start..data_end].to_vec();
|
||||
Ok((cmd_type, payload))
|
||||
}
|
||||
|
||||
// ==================== LOW-LEVEL: BINARY V2 ====================
|
||||
|
||||
/// Send a KS frame, read KR response.
|
||||
pub fn send_binary(&mut self, cmd_id: u8, payload: &[u8]) -> Result<KrResponse, String> {
|
||||
let frame = bp::ks_frame(cmd_id, payload);
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
|
||||
let write_result = port.write_all(&frame);
|
||||
write_result.map_err(|e| format!("Write: {}", e))?;
|
||||
|
||||
let flush_result = port.flush();
|
||||
flush_result.map_err(|e| format!("Flush: {}", e))?;
|
||||
|
||||
std::thread::sleep(Duration::from_millis(BINARY_SETTLE_MS));
|
||||
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
|
||||
|
||||
let (resp, _remaining) = bp::parse_kr(&raw)?;
|
||||
let firmware_ok = resp.is_ok();
|
||||
if !firmware_ok {
|
||||
let status = resp.status_name();
|
||||
return Err(format!("Firmware error: {}", status));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// ==================== HIGH-LEVEL: AUTO V2/LEGACY ====================
|
||||
|
||||
pub fn get_firmware_version(&mut self) -> Option<String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let binary_result = self.send_binary(bp::cmd::VERSION, &[]);
|
||||
match binary_result {
|
||||
Ok(resp) => {
|
||||
let raw_bytes = &resp.payload;
|
||||
let lossy_string = String::from_utf8_lossy(raw_bytes);
|
||||
let version = lossy_string.to_string();
|
||||
return Some(version);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback
|
||||
let query_result = self.query_command("VERSION?");
|
||||
let lines = match query_result {
|
||||
Ok(l) => l,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let mut line_iter = lines.into_iter();
|
||||
let first_line = line_iter.next();
|
||||
first_line
|
||||
}
|
||||
|
||||
pub fn get_keymap(&mut self, layer: u8) -> Result<Vec<Vec<u16>>, String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let resp = self.send_binary(bp::cmd::KEYMAP_GET, &[layer])?;
|
||||
let keymap = self.parse_keymap_payload(&resp.payload)?;
|
||||
return Ok(keymap);
|
||||
}
|
||||
|
||||
// Legacy
|
||||
let cmd = format!("KEYMAP{}", layer);
|
||||
let (cmd_type, data) = self.query_legacy_binary(&cmd)?;
|
||||
|
||||
if cmd_type != 1 {
|
||||
return Err(format!("Unexpected cmd type: {}", cmd_type));
|
||||
}
|
||||
if data.len() < 2 {
|
||||
return Err("Data too short".into());
|
||||
}
|
||||
|
||||
// skip 2-byte layer index in legacy
|
||||
let data_without_header = &data[2..];
|
||||
self.parse_keymap_payload(data_without_header)
|
||||
}
|
||||
|
||||
fn parse_keymap_payload(&self, data: &[u8]) -> Result<Vec<Vec<u16>>, String> {
|
||||
// v2: [layer:u8][keycodes...] -- skip first byte
|
||||
// legacy: already stripped
|
||||
let expected_with_layer_byte = 1 + ROWS * COLS * 2;
|
||||
let has_layer_byte = data.len() >= expected_with_layer_byte;
|
||||
let offset = if has_layer_byte { 1 } else { 0 };
|
||||
let kc_data = &data[offset..];
|
||||
|
||||
let needed_bytes = ROWS * COLS * 2;
|
||||
if kc_data.len() < needed_bytes {
|
||||
return Err(format!("Keymap data too short: {} bytes (need {})", kc_data.len(), needed_bytes));
|
||||
}
|
||||
|
||||
let mut keymap = Vec::with_capacity(ROWS);
|
||||
|
||||
for row_index in 0..ROWS {
|
||||
let mut row = Vec::with_capacity(COLS);
|
||||
|
||||
for col_index in 0..COLS {
|
||||
let idx = (row_index * COLS + col_index) * 2;
|
||||
let low_byte = kc_data[idx] as u16;
|
||||
let high_byte = (kc_data[idx + 1] as u16) << 8;
|
||||
let keycode = low_byte | high_byte;
|
||||
row.push(keycode);
|
||||
}
|
||||
|
||||
keymap.push(row);
|
||||
}
|
||||
|
||||
Ok(keymap)
|
||||
}
|
||||
|
||||
pub fn get_layer_names(&mut self) -> Result<Vec<String>, String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let resp = self.send_binary(bp::cmd::LIST_LAYOUTS, &[])?;
|
||||
|
||||
let payload = &resp.payload;
|
||||
if payload.is_empty() {
|
||||
return Err("Empty response".into());
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let mut names = Vec::with_capacity(count);
|
||||
let mut i = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
let remaining = payload.len();
|
||||
let need_header = i + 2;
|
||||
if need_header > remaining {
|
||||
break;
|
||||
}
|
||||
|
||||
let _layer_index = payload[i];
|
||||
let name_len = payload[i + 1] as usize;
|
||||
i += 2;
|
||||
|
||||
let need_name = i + name_len;
|
||||
if need_name > payload.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let name_bytes = &payload[i..i + name_len];
|
||||
let name_lossy = String::from_utf8_lossy(name_bytes);
|
||||
let name = name_lossy.to_string();
|
||||
names.push(name);
|
||||
i += name_len;
|
||||
}
|
||||
|
||||
let found_names = !names.is_empty();
|
||||
if found_names {
|
||||
return Ok(names);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback: try C> binary protocol
|
||||
let legacy_result = self.query_legacy_binary("LAYOUTS?");
|
||||
match legacy_result {
|
||||
Ok((cmd_type, data)) => {
|
||||
let is_layout_type = cmd_type == 4;
|
||||
let has_data = !data.is_empty();
|
||||
|
||||
if is_layout_type && has_data {
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
let parts = text.split(';');
|
||||
let non_empty = parts.filter(|s| !s.is_empty());
|
||||
let trimmed_names = non_empty.map(|s| {
|
||||
let long_enough = s.len() > 1;
|
||||
if long_enough {
|
||||
s[1..].to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
});
|
||||
let names: Vec<String> = trimmed_names.collect();
|
||||
|
||||
let found_names = !names.is_empty();
|
||||
if found_names {
|
||||
return Ok(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
// Last resort: raw text
|
||||
self.send_command("LAYOUTS?")?;
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS * 2));
|
||||
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
|
||||
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
let split_by_delimiters = text.split(|c: char| c == ';' || c == '\n');
|
||||
|
||||
let cleaned = split_by_delimiters.map(|s| {
|
||||
let step1 = s.trim();
|
||||
let step2 = step1.trim_matches(|c: char| c.is_control() || c == '"');
|
||||
step2
|
||||
});
|
||||
|
||||
let valid_names = cleaned.filter(|s| {
|
||||
let is_not_empty = !s.is_empty();
|
||||
let is_short_enough = s.len() < 30;
|
||||
let no_header_marker = !s.contains("C>");
|
||||
let not_ok = *s != "OK";
|
||||
is_not_empty && is_short_enough && no_header_marker && not_ok
|
||||
});
|
||||
|
||||
let as_strings = valid_names.map(|s| s.to_string());
|
||||
let names: Vec<String> = as_strings.collect();
|
||||
|
||||
let found_any = !names.is_empty();
|
||||
if found_any {
|
||||
Ok(names)
|
||||
} else {
|
||||
Err("No layer names found".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_layout_json(&mut self) -> Result<String, String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let resp = self.send_binary(bp::cmd::GET_LAYOUT_JSON, &[])?;
|
||||
let has_payload = !resp.payload.is_empty();
|
||||
if has_payload {
|
||||
let raw_bytes = &resp.payload;
|
||||
let lossy_string = String::from_utf8_lossy(raw_bytes);
|
||||
let json = lossy_string.to_string();
|
||||
return Ok(json);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: text brace-counting
|
||||
self.send_command("LAYOUT?")?;
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
|
||||
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let mut result = String::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
let start = Instant::now();
|
||||
let max_wait = Duration::from_secs(JSON_TIMEOUT_SECS);
|
||||
let mut brace_count: i32 = 0;
|
||||
let mut started = false;
|
||||
|
||||
while start.elapsed() < max_wait {
|
||||
let read_result = port.read(&mut buf);
|
||||
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
let received_bytes = &buf[..n];
|
||||
let chunk = String::from_utf8_lossy(received_bytes);
|
||||
|
||||
for ch in chunk.chars() {
|
||||
let is_open_brace = ch == '{';
|
||||
if is_open_brace {
|
||||
started = true;
|
||||
brace_count += 1;
|
||||
}
|
||||
|
||||
if started {
|
||||
result.push(ch);
|
||||
}
|
||||
|
||||
let is_close_brace = ch == '}';
|
||||
if is_close_brace && started {
|
||||
brace_count -= 1;
|
||||
let json_complete = brace_count == 0;
|
||||
if json_complete {
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let got_nothing = result.is_empty();
|
||||
if got_nothing {
|
||||
Err("No JSON".into())
|
||||
} else {
|
||||
Err("Incomplete JSON".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper
|
||||
pub type SharedSerial = Arc<Mutex<SerialManager>>;
|
||||
|
||||
pub fn new_shared() -> SharedSerial {
|
||||
let manager = SerialManager::new();
|
||||
let mutex = Mutex::new(manager);
|
||||
let shared = Arc::new(mutex);
|
||||
shared
|
||||
}
|
||||
118
original-src/settings.rs
Normal file
118
original-src/settings.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/// Persistent settings for KaSe Controller.
|
||||
///
|
||||
/// - **Native**: `kase_settings.json` next to the executable.
|
||||
/// - **WASM**: `localStorage` under key `"kase_settings"`.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default = "default_layout")]
|
||||
pub keyboard_layout: String,
|
||||
}
|
||||
|
||||
fn default_layout() -> String {
|
||||
"QWERTY".to_string()
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keyboard_layout: default_layout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Native: JSON file next to executable
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_settings {
|
||||
use super::Settings;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Config file path: next to the executable.
|
||||
fn settings_path() -> PathBuf {
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let parent_dir = exe.parent().unwrap_or(std::path::Path::new("."));
|
||||
parent_dir.join("kase_settings.json")
|
||||
}
|
||||
|
||||
pub fn load() -> Settings {
|
||||
let path = settings_path();
|
||||
let json_content = std::fs::read_to_string(&path).ok();
|
||||
let parsed = json_content.and_then(|s| serde_json::from_str(&s).ok());
|
||||
parsed.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(settings: &Settings) {
|
||||
let path = settings_path();
|
||||
let json_result = serde_json::to_string_pretty(settings);
|
||||
if let Ok(json) = json_result {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WASM: browser localStorage
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web_settings {
|
||||
use super::Settings;
|
||||
|
||||
const STORAGE_KEY: &str = "kase_settings";
|
||||
|
||||
/// Get localStorage. Returns None if not in a browser context.
|
||||
fn get_storage() -> Option<web_sys::Storage> {
|
||||
let window = web_sys::window()?;
|
||||
window.local_storage().ok().flatten()
|
||||
}
|
||||
|
||||
pub fn load() -> Settings {
|
||||
let storage = match get_storage() {
|
||||
Some(s) => s,
|
||||
None => return Settings::default(),
|
||||
};
|
||||
|
||||
let json_option = storage.get_item(STORAGE_KEY).ok().flatten();
|
||||
let parsed = json_option.and_then(|s| serde_json::from_str(&s).ok());
|
||||
parsed.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(settings: &Settings) {
|
||||
let storage = match get_storage() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let json_result = serde_json::to_string(settings);
|
||||
if let Ok(json) = json_result {
|
||||
let _ = storage.set_item(STORAGE_KEY, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public interface
|
||||
// =============================================================================
|
||||
|
||||
/// Load settings from persistent storage.
|
||||
/// Returns `Settings::default()` if none found.
|
||||
pub fn load() -> Settings {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return native_settings::load();
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return web_settings::load();
|
||||
}
|
||||
|
||||
/// Save settings to persistent storage. Fails silently.
|
||||
pub fn save(settings: &Settings) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
native_settings::save(settings);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_settings::save(settings);
|
||||
}
|
||||
335
original-src/stats_analyzer.rs
Normal file
335
original-src/stats_analyzer.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/// Stats analysis: hand balance, finger load, row usage, top keys, bigrams.
|
||||
/// Transforms raw heatmap data into structured analysis for the stats tab.
|
||||
|
||||
use crate::keycode;
|
||||
use crate::parsers::ROWS;
|
||||
|
||||
/// Which hand a column belongs to.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Hand {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Which finger a column belongs to.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Finger {
|
||||
Pinky,
|
||||
Ring,
|
||||
Middle,
|
||||
Index,
|
||||
Thumb,
|
||||
}
|
||||
|
||||
/// Row names for the 5 rows.
|
||||
const ROW_NAMES: [&str; 5] = ["Number", "Upper", "Home", "Lower", "Thumb"];
|
||||
|
||||
/// Finger names (French).
|
||||
const FINGER_NAMES: [&str; 5] = ["Pinky", "Ring", "Middle", "Index", "Thumb"];
|
||||
|
||||
/// Map column index → (Hand, Finger).
|
||||
/// KaSe layout: cols 0-5 = left hand, cols 6 (gap), cols 7-12 = right hand.
|
||||
fn col_to_hand_finger(col: usize) -> (Hand, Finger) {
|
||||
match col {
|
||||
0 => (Hand::Left, Finger::Pinky),
|
||||
1 => (Hand::Left, Finger::Ring),
|
||||
2 => (Hand::Left, Finger::Middle),
|
||||
3 => (Hand::Left, Finger::Index),
|
||||
4 => (Hand::Left, Finger::Index), // inner column, still index
|
||||
5 => (Hand::Left, Finger::Thumb),
|
||||
6 => (Hand::Left, Finger::Thumb), // center / gap
|
||||
7 => (Hand::Right, Finger::Thumb),
|
||||
8 => (Hand::Right, Finger::Index),
|
||||
9 => (Hand::Right, Finger::Index), // inner column
|
||||
10 => (Hand::Right, Finger::Middle),
|
||||
11 => (Hand::Right, Finger::Ring),
|
||||
12 => (Hand::Right, Finger::Pinky),
|
||||
_ => (Hand::Left, Finger::Pinky),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand balance result.
|
||||
#[allow(dead_code)]
|
||||
pub struct HandBalance {
|
||||
pub left_count: u32,
|
||||
pub right_count: u32,
|
||||
pub total: u32,
|
||||
pub left_pct: f32,
|
||||
pub right_pct: f32,
|
||||
}
|
||||
|
||||
/// Finger load for one finger.
|
||||
#[allow(dead_code)]
|
||||
pub struct FingerLoad {
|
||||
pub name: String,
|
||||
pub hand: Hand,
|
||||
pub count: u32,
|
||||
pub pct: f32,
|
||||
}
|
||||
|
||||
/// Row usage for one row.
|
||||
#[allow(dead_code)]
|
||||
pub struct RowUsage {
|
||||
pub name: String,
|
||||
pub row: usize,
|
||||
pub count: u32,
|
||||
pub pct: f32,
|
||||
}
|
||||
|
||||
/// A key in the top keys ranking.
|
||||
#[allow(dead_code)]
|
||||
pub struct TopKey {
|
||||
pub name: String,
|
||||
pub finger: String,
|
||||
pub count: u32,
|
||||
pub pct: f32,
|
||||
}
|
||||
|
||||
/// Compute hand balance from heatmap data.
|
||||
pub fn hand_balance(heatmap: &[Vec<u32>]) -> HandBalance {
|
||||
let mut left: u32 = 0;
|
||||
let mut right: u32 = 0;
|
||||
|
||||
for row in heatmap {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
let (hand, _) = col_to_hand_finger(c);
|
||||
match hand {
|
||||
Hand::Left => left += count,
|
||||
Hand::Right => right += count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = left + right;
|
||||
let left_pct = if total > 0 { left as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let right_pct = if total > 0 { right as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
|
||||
HandBalance { left_count: left, right_count: right, total, left_pct, right_pct }
|
||||
}
|
||||
|
||||
/// Compute finger load (10 fingers: 5 left + 5 right).
|
||||
pub fn finger_load(heatmap: &[Vec<u32>]) -> Vec<FingerLoad> {
|
||||
let mut counts = [[0u32; 5]; 2]; // [hand][finger]
|
||||
|
||||
for row in heatmap {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
let (hand, finger) = col_to_hand_finger(c);
|
||||
let hi = if hand == Hand::Left { 0 } else { 1 };
|
||||
let fi = match finger {
|
||||
Finger::Pinky => 0,
|
||||
Finger::Ring => 1,
|
||||
Finger::Middle => 2,
|
||||
Finger::Index => 3,
|
||||
Finger::Thumb => 4,
|
||||
};
|
||||
counts[hi][fi] += count;
|
||||
}
|
||||
}
|
||||
|
||||
let total: u32 = counts[0].iter().sum::<u32>() + counts[1].iter().sum::<u32>();
|
||||
let mut result = Vec::with_capacity(10);
|
||||
|
||||
// Left hand fingers
|
||||
for fi in 0..5 {
|
||||
let count = counts[0][fi];
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let name = format!("{} L", FINGER_NAMES[fi]);
|
||||
result.push(FingerLoad { name, hand: Hand::Left, count, pct });
|
||||
}
|
||||
|
||||
// Right hand fingers
|
||||
for fi in 0..5 {
|
||||
let count = counts[1][fi];
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let name = format!("{} R", FINGER_NAMES[fi]);
|
||||
result.push(FingerLoad { name, hand: Hand::Right, count, pct });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute row usage.
|
||||
pub fn row_usage(heatmap: &[Vec<u32>]) -> Vec<RowUsage> {
|
||||
let mut row_counts = [0u32; ROWS];
|
||||
|
||||
for (r, row) in heatmap.iter().enumerate() {
|
||||
if r >= ROWS { break; }
|
||||
let row_sum: u32 = row.iter().sum();
|
||||
row_counts[r] = row_sum;
|
||||
}
|
||||
|
||||
let total: u32 = row_counts.iter().sum();
|
||||
let mut result = Vec::with_capacity(ROWS);
|
||||
|
||||
for r in 0..ROWS {
|
||||
let count = row_counts[r];
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let name = ROW_NAMES[r].to_string();
|
||||
result.push(RowUsage { name, row: r, count, pct });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute top N keys by press count.
|
||||
pub fn top_keys(heatmap: &[Vec<u32>], keymap: &[Vec<u16>], n: usize) -> Vec<TopKey> {
|
||||
let mut all_keys: Vec<(u32, usize, usize)> = Vec::new(); // (count, row, col)
|
||||
|
||||
for (r, row) in heatmap.iter().enumerate() {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
if count > 0 {
|
||||
all_keys.push((count, r, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_keys.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
all_keys.truncate(n);
|
||||
|
||||
let total: u32 = heatmap.iter().flat_map(|r| r.iter()).sum();
|
||||
|
||||
let mut result = Vec::with_capacity(n);
|
||||
for (count, r, c) in all_keys {
|
||||
let code = keymap.get(r).and_then(|row| row.get(c)).copied().unwrap_or(0);
|
||||
let name = keycode::decode_keycode(code);
|
||||
let (hand, finger) = col_to_hand_finger(c);
|
||||
let hand_str = if hand == Hand::Left { "L" } else { "R" };
|
||||
let finger_str = match finger {
|
||||
Finger::Pinky => "Pinky",
|
||||
Finger::Ring => "Ring",
|
||||
Finger::Middle => "Middle",
|
||||
Finger::Index => "Index",
|
||||
Finger::Thumb => "Thumb",
|
||||
};
|
||||
let finger_label = format!("{} {}", finger_str, hand_str);
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
result.push(TopKey { name, finger: finger_label, count, pct });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Find keys that have never been pressed (count = 0, keycode != 0).
|
||||
pub fn dead_keys(heatmap: &[Vec<u32>], keymap: &[Vec<u16>]) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for (r, row) in heatmap.iter().enumerate() {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
if count > 0 { continue; }
|
||||
let code = keymap.get(r).and_then(|row| row.get(c)).copied().unwrap_or(0);
|
||||
let is_mapped = code != 0;
|
||||
if is_mapped {
|
||||
let name = keycode::decode_keycode(code);
|
||||
result.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ==================== Bigram analysis ====================
|
||||
|
||||
/// A parsed bigram entry.
|
||||
pub struct BigramEntry {
|
||||
pub from_row: u8,
|
||||
pub from_col: u8,
|
||||
pub to_row: u8,
|
||||
pub to_col: u8,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// Bigram analysis results.
|
||||
#[allow(dead_code)]
|
||||
pub struct BigramAnalysis {
|
||||
pub total: u32,
|
||||
pub alt_hand: u32,
|
||||
pub same_hand: u32,
|
||||
pub sfb: u32,
|
||||
pub alt_hand_pct: f32,
|
||||
pub same_hand_pct: f32,
|
||||
pub sfb_pct: f32,
|
||||
}
|
||||
|
||||
/// Parse bigram text lines from firmware.
|
||||
/// Format: " R2C3 -> R2C4 : 150"
|
||||
pub fn parse_bigram_lines(lines: &[String]) -> Vec<BigramEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
let has_arrow = trimmed.contains("->");
|
||||
if !has_arrow { continue; }
|
||||
|
||||
let parts: Vec<&str> = trimmed.split("->").collect();
|
||||
if parts.len() != 2 { continue; }
|
||||
|
||||
let left = parts[0].trim();
|
||||
let right_and_count = parts[1].trim();
|
||||
|
||||
let right_parts: Vec<&str> = right_and_count.split(':').collect();
|
||||
if right_parts.len() != 2 { continue; }
|
||||
|
||||
let right = right_parts[0].trim();
|
||||
let count_str = right_parts[1].trim();
|
||||
|
||||
let from = parse_rc(left);
|
||||
let to = parse_rc(right);
|
||||
let count: u32 = count_str.parse().unwrap_or(0);
|
||||
|
||||
if let (Some((fr, fc)), Some((tr, tc))) = (from, to) {
|
||||
entries.push(BigramEntry {
|
||||
from_row: fr, from_col: fc,
|
||||
to_row: tr, to_col: tc,
|
||||
count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
/// Parse "R2C3" into (row, col).
|
||||
fn parse_rc(s: &str) -> Option<(u8, u8)> {
|
||||
let s = s.trim();
|
||||
let r_pos = s.find('R')?;
|
||||
let c_pos = s.find('C')?;
|
||||
if c_pos <= r_pos { return None; }
|
||||
|
||||
let row_str = &s[r_pos + 1..c_pos];
|
||||
let col_str = &s[c_pos + 1..];
|
||||
let row: u8 = row_str.parse().ok()?;
|
||||
let col: u8 = col_str.parse().ok()?;
|
||||
Some((row, col))
|
||||
}
|
||||
|
||||
/// Analyze bigram entries for hand alternation and SFB.
|
||||
pub fn analyze_bigrams(entries: &[BigramEntry]) -> BigramAnalysis {
|
||||
let mut alt_hand: u32 = 0;
|
||||
let mut same_hand: u32 = 0;
|
||||
let mut sfb: u32 = 0;
|
||||
let mut total: u32 = 0;
|
||||
|
||||
for entry in entries {
|
||||
let (hand_from, finger_from) = col_to_hand_finger(entry.from_col as usize);
|
||||
let (hand_to, finger_to) = col_to_hand_finger(entry.to_col as usize);
|
||||
|
||||
total += entry.count;
|
||||
|
||||
if hand_from != hand_to {
|
||||
alt_hand += entry.count;
|
||||
} else {
|
||||
same_hand += entry.count;
|
||||
if finger_from == finger_to {
|
||||
sfb += entry.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let alt_hand_pct = if total > 0 { alt_hand as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let same_hand_pct = if total > 0 { same_hand as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let sfb_pct = if total > 0 { sfb as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
|
||||
BigramAnalysis {
|
||||
total, alt_hand, same_hand, sfb,
|
||||
alt_hand_pct, same_hand_pct, sfb_pct,
|
||||
}
|
||||
}
|
||||
263
original-src/ui_background.rs
Normal file
263
original-src/ui_background.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
use super::BgResult;
|
||||
|
||||
/// Map a query tag to its binary command ID.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::binary_protocol as bp;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn tag_to_binary_cmd(tag: &str) -> Option<u8> {
|
||||
match tag {
|
||||
"td" => Some(bp::cmd::TD_LIST),
|
||||
"combo" => Some(bp::cmd::COMBO_LIST),
|
||||
"leader" => Some(bp::cmd::LEADER_LIST),
|
||||
"ko" => Some(bp::cmd::KO_LIST),
|
||||
"bt" => Some(bp::cmd::BT_QUERY),
|
||||
"tama" => Some(bp::cmd::TAMA_QUERY),
|
||||
"wpm" => Some(bp::cmd::WPM_QUERY),
|
||||
"macros" => Some(bp::cmd::LIST_MACROS),
|
||||
"keystats" => Some(bp::cmd::KEYSTATS_BIN),
|
||||
"features" => Some(bp::cmd::FEATURES),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl super::KaSeApp {
|
||||
// ---- Background helpers ----
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn bg_query(&mut self, tag: &str, cmd: &str) {
|
||||
self.busy = true;
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let tag = tag.to_string();
|
||||
let cmd = cmd.to_string();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let lines = ser.query_command(&cmd).unwrap_or_default();
|
||||
let _ = tx.send(BgResult::TextLines(tag, lines));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn bg_query(&mut self, tag: &str, cmd: &str) {
|
||||
if self.web_busy.get() { return; }
|
||||
self.busy = true;
|
||||
self.web_busy.set(true);
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
let tag = tag.to_string();
|
||||
let cmd = cmd.to_string();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let v2 = serial.borrow().v2;
|
||||
let handles = serial.borrow().io_handles();
|
||||
if let Ok((reader, writer)) = handles {
|
||||
if let Some(cmd_id) = v2.then(|| tag_to_binary_cmd(&tag)).flatten() {
|
||||
match crate::serial::send_binary(&reader, &writer, cmd_id, &[]).await {
|
||||
Ok(resp) => {
|
||||
let _ = tx.send(BgResult::BinaryPayload(tag, resp.payload));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
} else {
|
||||
let result = crate::serial::query_command(&reader, &writer, &cmd).await;
|
||||
match result {
|
||||
Ok(lines) => {
|
||||
let _ = tx.send(BgResult::TextLines(tag, lines));
|
||||
}
|
||||
Err(e) => {
|
||||
if e == "timeout_refresh" {
|
||||
serial.borrow_mut().refresh_reader();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
web_busy.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Run multiple queries sequentially (avoids mutex contention).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn bg_query_batch(&mut self, queries: &[(&str, &str)]) {
|
||||
self.busy = true;
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
|
||||
let owned_queries: Vec<(String, String)> = queries
|
||||
.iter()
|
||||
.map(|(t, c)| (t.to_string(), c.to_string()))
|
||||
.collect();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
|
||||
for (tag, cmd) in owned_queries {
|
||||
let lines = ser.query_command(&cmd).unwrap_or_default();
|
||||
let _ = tx.send(BgResult::TextLines(tag, lines));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn bg_query_batch(&mut self, queries: &[(&str, &str)]) {
|
||||
if self.web_busy.get() { return; }
|
||||
self.busy = true;
|
||||
self.web_busy.set(true);
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
|
||||
let owned: Vec<(String, String)> = queries
|
||||
.iter()
|
||||
.map(|(t, c)| (t.to_string(), c.to_string()))
|
||||
.collect();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let v2 = serial.borrow().v2;
|
||||
|
||||
for (tag, text_cmd) in &owned {
|
||||
let handles = serial.borrow().io_handles();
|
||||
let (reader, writer) = match handles {
|
||||
Ok(h) => h,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if let Some(cmd_id) = v2.then(|| tag_to_binary_cmd(tag)).flatten() {
|
||||
match crate::serial::send_binary(&reader, &writer, cmd_id, &[]).await {
|
||||
Ok(resp) => {
|
||||
let _ = tx.send(BgResult::BinaryPayload(tag.clone(), resp.payload));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
} else {
|
||||
match crate::serial::query_command(&reader, &writer, text_cmd).await {
|
||||
Ok(lines) => {
|
||||
let _ = tx.send(BgResult::TextLines(tag.clone(), lines));
|
||||
}
|
||||
Err(e) => {
|
||||
if e == "timeout_refresh" {
|
||||
serial.borrow_mut().refresh_reader();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
web_busy.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn bg_send(&self, cmd: &str) {
|
||||
let serial = self.serial.clone();
|
||||
let cmd = cmd.to_string();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _ = ser.send_command(&cmd);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn bg_send(&self, cmd: &str) {
|
||||
let serial = self.serial.clone();
|
||||
let cmd = cmd.to_string();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let handles = serial.borrow().io_handles();
|
||||
if let Ok((_reader, writer)) = handles {
|
||||
let _ = crate::serial::send_command(&writer, &cmd).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Data helpers ----
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_keymap(&mut self) {
|
||||
self.busy = true;
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let layer = self.current_layer as u8;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
|
||||
match ser.get_keymap(layer) {
|
||||
Ok(km) => { let _ = tx.send(BgResult::Keymap(km)); }
|
||||
Err(e) => { let _ = tx.send(BgResult::Error(e)); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn load_keymap(&mut self) {
|
||||
if self.web_busy.get() { return; }
|
||||
self.busy = true;
|
||||
self.web_busy.set(true);
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
let layer = self.current_layer as u8;
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let ser = serial.borrow();
|
||||
let v2 = ser.v2;
|
||||
let handles = ser.io_handles();
|
||||
drop(ser);
|
||||
match handles {
|
||||
Ok((reader, writer)) => {
|
||||
match crate::serial::get_keymap(&reader, &writer, layer, v2).await {
|
||||
Ok(km) => { let _ = tx.send(BgResult::Keymap(km)); }
|
||||
Err(e) => { let _ = tx.send(BgResult::Error(e)); }
|
||||
}
|
||||
}
|
||||
Err(e) => { let _ = tx.send(BgResult::Error(e)); }
|
||||
}
|
||||
web_busy.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_layer_names(&self) {
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
|
||||
match ser.get_layer_names() {
|
||||
Ok(names) if !names.is_empty() => {
|
||||
let _ = tx.send(BgResult::LayerNames(names));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn load_layer_names(&self) {
|
||||
if self.web_busy.get() { return; }
|
||||
self.web_busy.set(true);
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let ser = serial.borrow();
|
||||
let v2 = ser.v2;
|
||||
let handles = ser.io_handles();
|
||||
drop(ser);
|
||||
if let Ok((reader, writer)) = handles {
|
||||
match crate::serial::get_layer_names(&reader, &writer, v2).await {
|
||||
Ok(names) if !names.is_empty() => {
|
||||
let _ = tx.send(BgResult::LayerNames(names));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
web_busy.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
139
original-src/ui_connection.rs
Normal file
139
original-src/ui_connection.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use super::BgResult;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use eframe::egui;
|
||||
|
||||
impl super::KaSeApp {
|
||||
// ---- Connection helpers ----
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn is_connected(&self) -> bool {
|
||||
// Utilise le cache — PAS de serial.lock() ici !
|
||||
// Sinon le thread UI bloque quand un thread background
|
||||
// (OTA, query batch) tient le lock.
|
||||
self.connected_cache
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn is_connected(&self) -> bool {
|
||||
let ser = self.serial.borrow();
|
||||
ser.connected
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn connect(&mut self) {
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
self.busy = true;
|
||||
self.status_msg = "Scanning ports...".into();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = match serial.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
|
||||
match ser.auto_connect() {
|
||||
Ok(port_name) => {
|
||||
let fw = ser.get_firmware_version().unwrap_or_default();
|
||||
let names = ser.get_layer_names().unwrap_or_default();
|
||||
let km = ser.get_keymap(0).unwrap_or_default();
|
||||
let _ = tx.send(BgResult::Connected(port_name, fw, names, km));
|
||||
|
||||
// Try to fetch physical layout from firmware
|
||||
let layout = ser.get_layout_json()
|
||||
.ok()
|
||||
.and_then(|json| crate::layout::parse_json(&json).ok());
|
||||
if let Some(keys) = layout {
|
||||
let _ = tx.send(BgResult::LayoutJson(keys));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(BgResult::ConnectError(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn connect_web(&mut self, ctx: &egui::Context) {
|
||||
self.busy = true;
|
||||
self.web_busy.set(true);
|
||||
self.status_msg = "Selecting port...".into();
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
let ctx = ctx.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Step 1: async port selection (no borrow held)
|
||||
let conn = match crate::serial::request_port().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = tx.send(BgResult::ConnectError(e));
|
||||
web_busy.set(false);
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: extract handles before moving conn
|
||||
let reader = conn.reader.clone();
|
||||
let writer = conn.writer.clone();
|
||||
let v2 = conn.v2;
|
||||
|
||||
// Step 3: store connection
|
||||
serial.borrow_mut().apply_connection(conn);
|
||||
|
||||
// Step 4: async queries (no borrow held)
|
||||
let fw = crate::serial::get_firmware_version(&reader, &writer, v2)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let names = crate::serial::get_layer_names(&reader, &writer, v2)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let km = crate::serial::get_keymap(&reader, &writer, 0, v2)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let _ = tx.send(BgResult::Connected("WebSerial".into(), fw, names, km));
|
||||
|
||||
// Try to fetch physical layout from firmware
|
||||
let layout_json = crate::serial::get_layout_json(&reader, &writer, v2).await;
|
||||
if let Ok(json) = layout_json {
|
||||
if let Ok(keys) = crate::layout::parse_json(&json) {
|
||||
let _ = tx.send(BgResult::LayoutJson(keys));
|
||||
}
|
||||
}
|
||||
|
||||
web_busy.set(false);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn disconnect(&mut self) {
|
||||
let mut guard = match self.serial.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard.disconnect();
|
||||
drop(guard);
|
||||
|
||||
self.connected_cache = false;
|
||||
self.firmware_version.clear();
|
||||
self.keymap.clear();
|
||||
self.layer_names = vec!["Layer 0".into()];
|
||||
self.status_msg = "Disconnected".into();
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn disconnect(&mut self) {
|
||||
self.serial.borrow_mut().disconnect();
|
||||
|
||||
self.firmware_version.clear();
|
||||
self.keymap.clear();
|
||||
self.layer_names = vec!["Layer 0".into()];
|
||||
self.status_msg = "Disconnected".into();
|
||||
}
|
||||
}
|
||||
218
original-src/ui_helpers.rs
Normal file
218
original-src/ui_helpers.rs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
use super::{BgResult, Instant};
|
||||
|
||||
impl super::KaSeApp {
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn notify(&mut self, msg: &str) {
|
||||
let timestamp = Instant::now();
|
||||
let entry = (msg.to_string(), timestamp);
|
||||
self.notifications.push(entry);
|
||||
}
|
||||
|
||||
pub(super) fn get_heatmap_intensity(&self, row: usize, col: usize) -> f32 {
|
||||
if !self.heatmap_on || self.heatmap_max == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let row_data = self.heatmap_data.get(row);
|
||||
let cell_option = row_data.and_then(|r| r.get(col));
|
||||
let count = cell_option.copied().unwrap_or(0);
|
||||
|
||||
let count_float = count as f32;
|
||||
let max_float = self.heatmap_max as f32;
|
||||
let intensity = count_float / max_float;
|
||||
intensity
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_heatmap(&mut self) {
|
||||
self.busy = true;
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let lines = ser.query_command("KEYSTATS?").unwrap_or_default();
|
||||
let (data, max) = crate::parsers::parse_heatmap_lines(&lines);
|
||||
let _ = tx.send(BgResult::HeatmapData(data, max));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn load_heatmap(&mut self) {
|
||||
if self.web_busy.get() { return; }
|
||||
self.busy = true;
|
||||
self.web_busy.set(true);
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let handles = serial.borrow().io_handles();
|
||||
if let Ok((reader, writer)) = handles {
|
||||
let lines = crate::serial::query_command(&reader, &writer, "KEYSTATS?")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let (data, max) = crate::parsers::parse_heatmap_lines(&lines);
|
||||
let _ = tx.send(BgResult::HeatmapData(data, max));
|
||||
}
|
||||
web_busy.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// Apply a key selection from the key selector.
|
||||
/// row == 951: new KO result key
|
||||
/// row == 950: new KO trigger key
|
||||
/// row 900-949: KO edit (900 + idx*10 + 0=trig, +1=result)
|
||||
/// row >= 800: new leader result
|
||||
/// row >= 700: new leader sequence key (append)
|
||||
/// row >= 600: leader result edit (row-600 = leader index)
|
||||
/// row >= 500: leader sequence key edit (row = 500 + idx*10 + seq_pos)
|
||||
/// row >= 400: new combo result mode
|
||||
/// row >= 300: combo result edit (row-300 = combo index)
|
||||
/// row >= 200: macro step mode (add key as step)
|
||||
/// row >= 100: TD mode (row-100 = td index, col = action slot)
|
||||
/// row < 100: keymap mode
|
||||
pub(super) fn apply_key_selection(&mut self, row: usize, col: usize, code: u16) {
|
||||
if row == 951 {
|
||||
// New KO result key
|
||||
self.ko_new_res_key = code as u8;
|
||||
self.ko_new_res_set = true;
|
||||
self.status_msg = format!("KO result = {}", crate::keycode::hid_key_name(code as u8));
|
||||
return;
|
||||
}
|
||||
|
||||
if row == 950 {
|
||||
// New KO trigger key
|
||||
self.ko_new_trig_key = code as u8;
|
||||
self.ko_new_trig_set = true;
|
||||
self.status_msg = format!("KO trigger = {}", crate::keycode::hid_key_name(code as u8));
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 900 {
|
||||
// KO edit: row = 900 + idx*10 + field (0=trig, 1=result)
|
||||
let offset = row - 900;
|
||||
let ko_idx = offset / 10;
|
||||
let field = offset % 10;
|
||||
let idx_valid = ko_idx < self.ko_data.len();
|
||||
if idx_valid {
|
||||
if field == 0 {
|
||||
self.ko_data[ko_idx][0] = code as u8;
|
||||
self.status_msg = format!("KO #{} trigger = 0x{:02X}", ko_idx, code);
|
||||
} else {
|
||||
self.ko_data[ko_idx][2] = code as u8;
|
||||
self.status_msg = format!("KO #{} result = 0x{:02X}", ko_idx, code);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 800 {
|
||||
// New leader result
|
||||
self.leader_new_result = code as u8;
|
||||
self.leader_new_result_set = true;
|
||||
self.status_msg = format!("Leader result = {}", crate::keycode::hid_key_name(code as u8));
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 700 {
|
||||
// New leader sequence key (append)
|
||||
let seq_not_full = self.leader_new_seq.len() < 4;
|
||||
if seq_not_full {
|
||||
self.leader_new_seq.push(code as u8);
|
||||
let key_name = crate::keycode::hid_key_name(code as u8);
|
||||
self.status_msg = format!("Leader seq + {}", key_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 600 {
|
||||
// Leader result edit (existing)
|
||||
let leader_idx = row - 600;
|
||||
let idx_valid = leader_idx < self.leader_data.len();
|
||||
if idx_valid {
|
||||
self.leader_data[leader_idx].result = code as u8;
|
||||
self.status_msg = format!("Leader #{} result = 0x{:02X}", leader_idx, code);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 500 {
|
||||
// Leader sequence key edit (existing)
|
||||
// row = 500 + leader_idx*10 + seq_pos
|
||||
let offset = row - 500;
|
||||
let leader_idx = offset / 10;
|
||||
let seq_pos = offset % 10;
|
||||
let idx_valid = leader_idx < self.leader_data.len();
|
||||
if idx_valid {
|
||||
let seq_valid = seq_pos < self.leader_data[leader_idx].sequence.len();
|
||||
if seq_valid {
|
||||
self.leader_data[leader_idx].sequence[seq_pos] = code as u8;
|
||||
self.status_msg = format!("Leader #{} key {} = 0x{:02X}", leader_idx, seq_pos, code);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 400 {
|
||||
// New combo result mode
|
||||
self.combo_new_result = code;
|
||||
self.status_msg = format!("New combo result = 0x{:04X}", code);
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 300 {
|
||||
// Combo result edit mode
|
||||
let combo_idx = row - 300;
|
||||
let idx_valid = combo_idx < self.combo_data.len();
|
||||
if idx_valid {
|
||||
self.combo_data[combo_idx].result = code;
|
||||
self.status_msg = format!("Combo #{} result = 0x{:04X}", combo_idx, code);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 200 {
|
||||
// Macro step mode
|
||||
self.apply_macro_step(code);
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 100 {
|
||||
// TD mode
|
||||
let td_idx = row - 100;
|
||||
let idx_valid = td_idx < self.td_data.len();
|
||||
let col_valid = col < 4;
|
||||
|
||||
if idx_valid && col_valid {
|
||||
self.td_data[td_idx][col] = code;
|
||||
self.status_msg = format!("TD {} action {} = 0x{:04X}", td_idx, col, code);
|
||||
}
|
||||
} else {
|
||||
// Keymap mode - validate bounds BEFORE sending
|
||||
let row_valid = row < self.keymap.len();
|
||||
let col_valid = row_valid && col < self.keymap[row].len();
|
||||
|
||||
if col_valid {
|
||||
let layer = self.current_layer as u8;
|
||||
let row_byte = row as u8;
|
||||
let col_byte = col as u8;
|
||||
let cmd = crate::protocol::cmd_set_key(layer, row_byte, col_byte, code);
|
||||
self.bg_send(&cmd);
|
||||
|
||||
self.keymap[row][col] = code;
|
||||
self.status_msg = format!("[{},{}] = 0x{:04X}", row, col, code);
|
||||
} else {
|
||||
self.status_msg = format!("Invalid key position [{},{}]", row, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_key(&self, row: usize, col: usize) -> u16 {
|
||||
let row_data = self.keymap.get(row);
|
||||
let cell_option = row_data.and_then(|r| r.get(col));
|
||||
let value = cell_option.copied();
|
||||
value.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
277
original-src/ui_mod.rs
Normal file
277
original-src/ui_mod.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
mod background;
|
||||
mod connection;
|
||||
mod geometry;
|
||||
mod helpers;
|
||||
mod key_selector;
|
||||
mod render;
|
||||
mod tab_advanced;
|
||||
mod tab_keymap;
|
||||
mod tab_macros;
|
||||
mod tab_settings;
|
||||
mod tab_stats;
|
||||
mod update;
|
||||
|
||||
use std::sync::mpsc;
|
||||
use crate::serial::{self, SharedSerial};
|
||||
use crate::layout::{self, KeycapPos};
|
||||
use crate::layout_remap::KeyboardLayout;
|
||||
|
||||
// Instant doesn't exist in WASM - use a wrapper
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type Instant = std::time::Instant;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(Clone, Copy)]
|
||||
struct Instant(f64);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl Instant {
|
||||
fn now() -> Self {
|
||||
let window = web_sys::window().unwrap();
|
||||
let performance = window.performance().unwrap();
|
||||
Instant(performance.now())
|
||||
}
|
||||
fn elapsed(&self) -> std::time::Duration {
|
||||
let now = Self::now();
|
||||
let ms = now.0 - self.0;
|
||||
std::time::Duration::from_millis(ms as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Results from background serial operations.
|
||||
pub(super) enum BgResult {
|
||||
Connected(String, String, Vec<String>, Vec<Vec<u16>>),
|
||||
ConnectError(String),
|
||||
Keymap(Vec<Vec<u16>>),
|
||||
LayerNames(Vec<String>),
|
||||
TextLines(String, Vec<String>),
|
||||
#[allow(dead_code)] // constructed only in WASM builds
|
||||
BinaryPayload(String, Vec<u8>), // tag, raw KR payload
|
||||
LayoutJson(Vec<crate::layout::KeycapPos>), // physical key positions from firmware
|
||||
OtaProgress(f32, String), // progress 0-1, status message
|
||||
HeatmapData(Vec<Vec<u32>>, u32), // counts, max
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(super) enum Tab {
|
||||
Keymap,
|
||||
Advanced,
|
||||
Macros,
|
||||
Stats,
|
||||
Settings,
|
||||
}
|
||||
|
||||
pub struct KaSeApp {
|
||||
pub(super) serial: SharedSerial,
|
||||
pub(super) bg_tx: mpsc::Sender<BgResult>,
|
||||
pub(super) bg_rx: mpsc::Receiver<BgResult>,
|
||||
pub(super) busy: bool,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) web_busy: std::rc::Rc<std::cell::Cell<bool>>,
|
||||
pub(super) tab: Tab,
|
||||
|
||||
// Connection
|
||||
/// Cached connection status — avoids serial.lock() in views.
|
||||
/// Updated in poll_bg() when Connected/ConnectError is received.
|
||||
pub(super) connected_cache: bool,
|
||||
pub(super) firmware_version: String,
|
||||
|
||||
// Keymap
|
||||
pub(super) current_layer: usize,
|
||||
pub(super) layer_names: Vec<String>,
|
||||
pub(super) keymap: Vec<Vec<u16>>, // rows x cols
|
||||
pub(super) key_layout: Vec<KeycapPos>,
|
||||
pub(super) editing_key: Option<(usize, usize)>, // (row, col) being edited
|
||||
|
||||
// Keymap editing
|
||||
pub(super) layer_rename: String,
|
||||
|
||||
// Advanced
|
||||
pub(super) td_lines: Vec<String>,
|
||||
pub(super) td_data: Vec<[u16; 4]>, // parsed tap dance slots
|
||||
pub(super) combo_lines: Vec<String>,
|
||||
pub(super) combo_data: Vec<crate::parsers::ComboEntry>,
|
||||
/// None = pas de picking en cours. Some((combo_idx, slot)) = on attend un clic clavier.
|
||||
/// combo_idx: usize::MAX = nouveau combo. slot: 0 = key1, 1 = key2.
|
||||
pub(super) combo_picking: Option<(usize, u8)>,
|
||||
pub(super) combo_new_r1: u8,
|
||||
pub(super) combo_new_c1: u8,
|
||||
pub(super) combo_new_r2: u8,
|
||||
pub(super) combo_new_c2: u8,
|
||||
pub(super) combo_new_result: u16,
|
||||
pub(super) combo_new_key1_set: bool,
|
||||
pub(super) combo_new_key2_set: bool,
|
||||
pub(super) leader_lines: Vec<String>,
|
||||
pub(super) leader_data: Vec<crate::parsers::LeaderEntry>,
|
||||
// Leader editing: new sequence being built
|
||||
pub(super) leader_new_seq: Vec<u8>, // HID keycodes for the sequence
|
||||
pub(super) leader_new_result: u8,
|
||||
pub(super) leader_new_mod: u8,
|
||||
pub(super) leader_new_result_set: bool,
|
||||
pub(super) ko_lines: Vec<String>,
|
||||
pub(super) ko_data: Vec<[u8; 4]>, // parsed: [trigger, trig_mod, result, res_mod]
|
||||
pub(super) ko_new_trig_key: u8,
|
||||
pub(super) ko_new_trig_mod: u8,
|
||||
pub(super) ko_new_res_key: u8,
|
||||
pub(super) ko_new_res_mod: u8,
|
||||
pub(super) ko_new_trig_set: bool,
|
||||
pub(super) ko_new_res_set: bool,
|
||||
pub(super) bt_lines: Vec<String>,
|
||||
pub(super) tama_lines: Vec<String>,
|
||||
pub(super) wpm_text: String,
|
||||
pub(super) autoshift_status: String,
|
||||
pub(super) tri_l1: String,
|
||||
pub(super) tri_l2: String,
|
||||
pub(super) tri_l3: String,
|
||||
|
||||
// Macros
|
||||
pub(super) macro_lines: Vec<String>,
|
||||
pub(super) macro_data: Vec<crate::parsers::MacroEntry>,
|
||||
pub(super) macro_slot: String,
|
||||
pub(super) macro_name: String,
|
||||
pub(super) macro_steps: String,
|
||||
|
||||
// Stats / Heatmap
|
||||
pub(super) keystats_lines: Vec<String>,
|
||||
pub(super) bigrams_lines: Vec<String>,
|
||||
pub(super) heatmap_data: Vec<Vec<u32>>, // rows x cols press counts
|
||||
pub(super) heatmap_max: u32,
|
||||
pub(super) heatmap_on: bool,
|
||||
pub(super) heatmap_selected: Option<(usize, usize)>, // selected key for bigram view
|
||||
pub(super) stats_dirty: bool,
|
||||
|
||||
// Key selector
|
||||
pub(super) key_search: String,
|
||||
pub(super) mt_mod: u8,
|
||||
pub(super) mt_key: u8,
|
||||
pub(super) lt_layer: u8,
|
||||
pub(super) lt_key: u8,
|
||||
pub(super) hex_input: String,
|
||||
|
||||
// Settings
|
||||
pub(super) keyboard_layout: crate::layout_remap::KeyboardLayout,
|
||||
|
||||
// OTA
|
||||
pub(super) ota_path: String,
|
||||
pub(super) ota_status: String,
|
||||
pub(super) ota_firmware_data: Vec<u8>,
|
||||
pub(super) ota_progress: f32,
|
||||
pub(super) ota_releases: Vec<(String, String)>, // (tag_name, asset_url)
|
||||
pub(super) ota_selected_release: usize,
|
||||
|
||||
// Prog port flasher (native only)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) prog_port: String,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) prog_path: String,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) prog_ports_list: Vec<String>,
|
||||
|
||||
// Notifications
|
||||
pub(super) notifications: Vec<(String, Instant)>,
|
||||
|
||||
// Status
|
||||
pub(super) status_msg: String,
|
||||
pub(super) last_reconnect_poll: Instant,
|
||||
pub(super) last_port_check: Instant,
|
||||
pub(super) last_wpm_poll: Instant,
|
||||
pub(super) last_stats_refresh: Instant,
|
||||
}
|
||||
|
||||
impl KaSeApp {
|
||||
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||
let (bg_tx, bg_rx) = mpsc::channel();
|
||||
|
||||
let saved_settings = crate::settings::load();
|
||||
let keyboard_layout = KeyboardLayout::from_name(&saved_settings.keyboard_layout);
|
||||
|
||||
Self {
|
||||
serial: serial::new_shared(),
|
||||
bg_tx,
|
||||
bg_rx,
|
||||
busy: false,
|
||||
connected_cache: false,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_busy: std::rc::Rc::new(std::cell::Cell::new(false)),
|
||||
tab: Tab::Keymap,
|
||||
firmware_version: String::new(),
|
||||
current_layer: 0,
|
||||
layer_names: vec!["Layer 0".into()],
|
||||
keymap: Vec::new(),
|
||||
key_layout: layout::default_layout(),
|
||||
editing_key: None,
|
||||
layer_rename: String::new(),
|
||||
td_lines: Vec::new(),
|
||||
td_data: Vec::new(),
|
||||
combo_lines: Vec::new(),
|
||||
combo_data: Vec::new(),
|
||||
combo_picking: None,
|
||||
combo_new_r1: 0,
|
||||
combo_new_c1: 0,
|
||||
combo_new_r2: 0,
|
||||
combo_new_c2: 0,
|
||||
combo_new_result: 0,
|
||||
combo_new_key1_set: false,
|
||||
combo_new_key2_set: false,
|
||||
leader_new_seq: Vec::new(),
|
||||
leader_new_result: 0,
|
||||
leader_new_mod: 0,
|
||||
leader_new_result_set: false,
|
||||
leader_lines: Vec::new(),
|
||||
leader_data: Vec::new(),
|
||||
ko_lines: Vec::new(),
|
||||
ko_data: Vec::new(),
|
||||
ko_new_trig_key: 0,
|
||||
ko_new_trig_mod: 0,
|
||||
ko_new_res_key: 0,
|
||||
ko_new_res_mod: 0,
|
||||
ko_new_trig_set: false,
|
||||
ko_new_res_set: false,
|
||||
bt_lines: Vec::new(),
|
||||
macro_lines: Vec::new(),
|
||||
macro_data: Vec::new(),
|
||||
tama_lines: Vec::new(),
|
||||
wpm_text: String::new(),
|
||||
autoshift_status: String::new(),
|
||||
tri_l1: "1".into(),
|
||||
tri_l2: "2".into(),
|
||||
tri_l3: "3".into(),
|
||||
macro_slot: "0".into(),
|
||||
macro_name: String::new(),
|
||||
macro_steps: String::new(),
|
||||
keystats_lines: Vec::new(),
|
||||
bigrams_lines: Vec::new(),
|
||||
heatmap_data: Vec::new(),
|
||||
heatmap_max: 0,
|
||||
heatmap_on: false,
|
||||
heatmap_selected: None,
|
||||
stats_dirty: true,
|
||||
key_search: String::new(),
|
||||
mt_mod: 0x02, // HID Left Shift
|
||||
mt_key: 0x04, // HID 'A'
|
||||
lt_layer: 1,
|
||||
lt_key: 0x2C, // HID Space
|
||||
hex_input: String::new(),
|
||||
keyboard_layout,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
prog_port: String::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
prog_path: String::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
prog_ports_list: Vec::new(),
|
||||
notifications: Vec::new(),
|
||||
ota_path: String::new(),
|
||||
ota_status: String::new(),
|
||||
ota_firmware_data: Vec::new(),
|
||||
ota_progress: 0.0,
|
||||
ota_releases: Vec::new(),
|
||||
ota_selected_release: 0,
|
||||
status_msg: "Searching KeSp...".into(),
|
||||
last_reconnect_poll: Instant::now(),
|
||||
last_port_check: Instant::now(),
|
||||
last_wpm_poll: Instant::now(),
|
||||
last_stats_refresh: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
259
src/logic/binary_protocol.rs
Normal file
259
src/logic/binary_protocol.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/// KaSe Binary CDC Protocol v2
|
||||
/// Frame: KS(2) + cmd(1) + len(2 LE) + payload(N) + crc8(1)
|
||||
/// Response: KR(2) + cmd(1) + status(1) + len(2 LE) + payload(N) + crc8(1)
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub mod cmd {
|
||||
// System
|
||||
pub const VERSION: u8 = 0x01;
|
||||
pub const FEATURES: u8 = 0x02;
|
||||
pub const DFU: u8 = 0x03;
|
||||
pub const PING: u8 = 0x04;
|
||||
|
||||
// Keymap
|
||||
pub const SETLAYER: u8 = 0x10;
|
||||
pub const SETKEY: u8 = 0x11;
|
||||
pub const KEYMAP_CURRENT: u8 = 0x12;
|
||||
pub const KEYMAP_GET: u8 = 0x13;
|
||||
pub const LAYER_INDEX: u8 = 0x14;
|
||||
pub const LAYER_NAME: u8 = 0x15;
|
||||
|
||||
// Layout
|
||||
pub const SET_LAYOUT_NAME: u8 = 0x20;
|
||||
pub const LIST_LAYOUTS: u8 = 0x21;
|
||||
pub const GET_LAYOUT_JSON: u8 = 0x22;
|
||||
|
||||
// Macros
|
||||
pub const LIST_MACROS: u8 = 0x30;
|
||||
pub const MACRO_ADD: u8 = 0x31;
|
||||
pub const MACRO_ADD_SEQ: u8 = 0x32;
|
||||
pub const MACRO_DELETE: u8 = 0x33;
|
||||
|
||||
// Statistics
|
||||
pub const KEYSTATS_BIN: u8 = 0x40;
|
||||
pub const KEYSTATS_RESET: u8 = 0x42;
|
||||
pub const BIGRAMS_BIN: u8 = 0x43;
|
||||
pub const BIGRAMS_RESET: u8 = 0x45;
|
||||
|
||||
// Tap Dance
|
||||
pub const TD_SET: u8 = 0x50;
|
||||
pub const TD_LIST: u8 = 0x51;
|
||||
pub const TD_DELETE: u8 = 0x52;
|
||||
|
||||
// Combos
|
||||
pub const COMBO_SET: u8 = 0x60;
|
||||
pub const COMBO_LIST: u8 = 0x61;
|
||||
pub const COMBO_DELETE: u8 = 0x62;
|
||||
|
||||
// Leader
|
||||
pub const LEADER_SET: u8 = 0x70;
|
||||
pub const LEADER_LIST: u8 = 0x71;
|
||||
pub const LEADER_DELETE: u8 = 0x72;
|
||||
|
||||
// Bluetooth
|
||||
pub const BT_QUERY: u8 = 0x80;
|
||||
pub const BT_SWITCH: u8 = 0x81;
|
||||
pub const BT_PAIR: u8 = 0x82;
|
||||
pub const BT_DISCONNECT: u8 = 0x83;
|
||||
pub const BT_NEXT: u8 = 0x84;
|
||||
pub const BT_PREV: u8 = 0x85;
|
||||
|
||||
// Features
|
||||
pub const AUTOSHIFT_TOGGLE: u8 = 0x90;
|
||||
pub const KO_SET: u8 = 0x91;
|
||||
pub const KO_LIST: u8 = 0x92;
|
||||
pub const KO_DELETE: u8 = 0x93;
|
||||
pub const WPM_QUERY: u8 = 0x94;
|
||||
pub const TRILAYER_SET: u8 = 0x94;
|
||||
|
||||
// Tamagotchi
|
||||
pub const TAMA_QUERY: u8 = 0xA0;
|
||||
pub const TAMA_ENABLE: u8 = 0xA1;
|
||||
pub const TAMA_DISABLE: u8 = 0xA2;
|
||||
pub const TAMA_FEED: u8 = 0xA3;
|
||||
pub const TAMA_PLAY: u8 = 0xA4;
|
||||
pub const TAMA_SLEEP: u8 = 0xA5;
|
||||
pub const TAMA_MEDICINE: u8 = 0xA6;
|
||||
pub const TAMA_SAVE: u8 = 0xA7;
|
||||
|
||||
// OTA
|
||||
pub const OTA_START: u8 = 0xF0;
|
||||
pub const OTA_DATA: u8 = 0xF1;
|
||||
pub const OTA_ABORT: u8 = 0xF2;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub mod status {
|
||||
pub const OK: u8 = 0x00;
|
||||
pub const ERR_UNKNOWN: u8 = 0x01;
|
||||
pub const ERR_CRC: u8 = 0x02;
|
||||
pub const ERR_INVALID: u8 = 0x03;
|
||||
pub const ERR_RANGE: u8 = 0x04;
|
||||
pub const ERR_BUSY: u8 = 0x05;
|
||||
pub const ERR_OVERFLOW: u8 = 0x06;
|
||||
}
|
||||
|
||||
/// CRC-8/MAXIM (polynomial 0x31, init 0x00)
|
||||
pub fn crc8(data: &[u8]) -> u8 {
|
||||
let mut crc: u8 = 0x00;
|
||||
for &b in data {
|
||||
crc ^= b;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x80 != 0 {
|
||||
(crc << 1) ^ 0x31
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Build a KS request frame.
|
||||
pub fn ks_frame(cmd_id: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let len = payload.len() as u16;
|
||||
let mut frame = Vec::with_capacity(6 + payload.len());
|
||||
frame.push(0x4B); // 'K'
|
||||
frame.push(0x53); // 'S'
|
||||
frame.push(cmd_id);
|
||||
frame.push((len & 0xFF) as u8);
|
||||
frame.push((len >> 8) as u8);
|
||||
frame.extend_from_slice(payload);
|
||||
frame.push(crc8(payload));
|
||||
frame
|
||||
}
|
||||
|
||||
/// Build MACRO_ADD_SEQ payload: [slot][name_len][name...][step_count][{kc,mod}...]
|
||||
pub fn macro_add_seq_payload(slot: u8, name: &str, steps_hex: &str) -> Vec<u8> {
|
||||
let name_bytes = name.as_bytes();
|
||||
let name_len = name_bytes.len().min(255) as u8;
|
||||
|
||||
// Parse hex steps "06:01,FF:0A,19:01" into (kc, mod) pairs
|
||||
let mut step_pairs: Vec<(u8, u8)> = Vec::new();
|
||||
if !steps_hex.is_empty() {
|
||||
for part in steps_hex.split(',') {
|
||||
let trimmed = part.trim();
|
||||
let kv: Vec<&str> = trimmed.split(':').collect();
|
||||
let has_two = kv.len() == 2;
|
||||
if !has_two {
|
||||
continue;
|
||||
}
|
||||
let kc = u8::from_str_radix(kv[0].trim(), 16).unwrap_or(0);
|
||||
let md = u8::from_str_radix(kv[1].trim(), 16).unwrap_or(0);
|
||||
step_pairs.push((kc, md));
|
||||
}
|
||||
}
|
||||
let step_count = step_pairs.len().min(255) as u8;
|
||||
|
||||
let mut payload = Vec::new();
|
||||
payload.push(slot);
|
||||
payload.push(name_len);
|
||||
payload.extend_from_slice(&name_bytes[..name_len as usize]);
|
||||
payload.push(step_count);
|
||||
for (kc, md) in &step_pairs {
|
||||
payload.push(*kc);
|
||||
payload.push(*md);
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
/// Build MACRO_DELETE payload: [slot]
|
||||
pub fn macro_delete_payload(slot: u8) -> Vec<u8> {
|
||||
vec![slot]
|
||||
}
|
||||
|
||||
/// Build COMBO_SET payload: [index][r1][c1][r2][c2][result]
|
||||
pub fn combo_set_payload(index: u8, r1: u8, c1: u8, r2: u8, c2: u8, result: u8) -> Vec<u8> {
|
||||
vec![index, r1, c1, r2, c2, result]
|
||||
}
|
||||
|
||||
/// Build TD_SET payload: [index][a1][a2][a3][a4]
|
||||
pub fn td_set_payload(index: u8, actions: &[u16; 4]) -> Vec<u8> {
|
||||
vec![index, actions[0] as u8, actions[1] as u8, actions[2] as u8, actions[3] as u8]
|
||||
}
|
||||
|
||||
/// Build KO_SET payload: [index][trigger_key][trigger_mod][result_key][result_mod]
|
||||
pub fn ko_set_payload(index: u8, trig_key: u8, trig_mod: u8, res_key: u8, res_mod: u8) -> Vec<u8> {
|
||||
vec![index, trig_key, trig_mod, res_key, res_mod]
|
||||
}
|
||||
|
||||
/// Build LEADER_SET payload: [index][seq_len][seq...][result][result_mod]
|
||||
pub fn leader_set_payload(index: u8, sequence: &[u8], result: u8, result_mod: u8) -> Vec<u8> {
|
||||
let seq_len = sequence.len().min(4) as u8;
|
||||
let mut payload = Vec::with_capacity(4 + sequence.len());
|
||||
payload.push(index);
|
||||
payload.push(seq_len);
|
||||
payload.extend_from_slice(&sequence[..seq_len as usize]);
|
||||
payload.push(result);
|
||||
payload.push(result_mod);
|
||||
payload
|
||||
}
|
||||
|
||||
/// Parsed KR response.
|
||||
#[derive(Debug)]
|
||||
pub struct KrResponse {
|
||||
#[allow(dead_code)]
|
||||
pub cmd: u8,
|
||||
pub status: u8,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl KrResponse {
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.status == status::OK
|
||||
}
|
||||
|
||||
pub fn status_name(&self) -> &str {
|
||||
match self.status {
|
||||
status::OK => "OK",
|
||||
status::ERR_UNKNOWN => "ERR_UNKNOWN",
|
||||
status::ERR_CRC => "ERR_CRC",
|
||||
status::ERR_INVALID => "ERR_INVALID",
|
||||
status::ERR_RANGE => "ERR_RANGE",
|
||||
status::ERR_BUSY => "ERR_BUSY",
|
||||
status::ERR_OVERFLOW => "ERR_OVERFLOW",
|
||||
_ => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a KR response from raw bytes. Returns (response, bytes_consumed).
|
||||
pub fn parse_kr(data: &[u8]) -> Result<(KrResponse, usize), String> {
|
||||
// Find KR magic
|
||||
let pos = data
|
||||
.windows(2)
|
||||
.position(|w| w[0] == 0x4B && w[1] == 0x52)
|
||||
.ok_or("No KR header found")?;
|
||||
|
||||
if data.len() < pos + 7 {
|
||||
return Err("Response too short".into());
|
||||
}
|
||||
|
||||
let cmd = data[pos + 2];
|
||||
let status = data[pos + 3];
|
||||
let plen = data[pos + 4] as u16 | ((data[pos + 5] as u16) << 8);
|
||||
let payload_start = pos + 6;
|
||||
let payload_end = payload_start + plen as usize;
|
||||
|
||||
if data.len() < payload_end + 1 {
|
||||
return Err(format!(
|
||||
"Incomplete response: need {} bytes, got {}",
|
||||
payload_end + 1,
|
||||
data.len()
|
||||
));
|
||||
}
|
||||
|
||||
let payload = data[payload_start..payload_end].to_vec();
|
||||
let expected_crc = data[payload_end];
|
||||
let actual_crc = crc8(&payload);
|
||||
|
||||
if expected_crc != actual_crc {
|
||||
return Err(format!(
|
||||
"CRC mismatch: expected 0x{:02X}, got 0x{:02X}",
|
||||
expected_crc, actual_crc
|
||||
));
|
||||
}
|
||||
|
||||
let consumed = payload_end + 1 - pos;
|
||||
Ok((KrResponse { cmd, status, payload }, consumed))
|
||||
}
|
||||
523
src/logic/flasher.rs
Normal file
523
src/logic/flasher.rs
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
/// ESP32 ROM bootloader flasher via serial (CH340/CP2102 programming port).
|
||||
/// Implements minimal SLIP-framed bootloader protocol for firmware flashing
|
||||
/// without requiring esptool.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use serialport::SerialPort;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::mpsc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Progress message sent back to the UI during flashing.
|
||||
/// Replaces the old `ui::BgResult::OtaProgress` variant.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub enum FlashProgress {
|
||||
OtaProgress(f32, String),
|
||||
}
|
||||
|
||||
// ==================== SLIP framing ====================
|
||||
|
||||
const SLIP_END: u8 = 0xC0;
|
||||
const SLIP_ESC: u8 = 0xDB;
|
||||
const SLIP_ESC_END: u8 = 0xDC;
|
||||
const SLIP_ESC_ESC: u8 = 0xDD;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn slip_encode(data: &[u8]) -> Vec<u8> {
|
||||
let mut frame = Vec::with_capacity(data.len() + 10);
|
||||
frame.push(SLIP_END);
|
||||
for &byte in data {
|
||||
match byte {
|
||||
SLIP_END => {
|
||||
frame.push(SLIP_ESC);
|
||||
frame.push(SLIP_ESC_END);
|
||||
}
|
||||
SLIP_ESC => {
|
||||
frame.push(SLIP_ESC);
|
||||
frame.push(SLIP_ESC_ESC);
|
||||
}
|
||||
_ => frame.push(byte),
|
||||
}
|
||||
}
|
||||
frame.push(SLIP_END);
|
||||
frame
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn slip_decode(frame: &[u8]) -> Vec<u8> {
|
||||
let mut data = Vec::with_capacity(frame.len());
|
||||
let mut escaped = false;
|
||||
for &byte in frame {
|
||||
if escaped {
|
||||
match byte {
|
||||
SLIP_ESC_END => data.push(SLIP_END),
|
||||
SLIP_ESC_ESC => data.push(SLIP_ESC),
|
||||
_ => data.push(byte),
|
||||
}
|
||||
escaped = false;
|
||||
} else if byte == SLIP_ESC {
|
||||
escaped = true;
|
||||
} else if byte != SLIP_END {
|
||||
data.push(byte);
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
// ==================== Bootloader commands ====================
|
||||
|
||||
const CMD_SYNC: u8 = 0x08;
|
||||
const CMD_CHANGE_BAUDRATE: u8 = 0x0F;
|
||||
const CMD_SPI_ATTACH: u8 = 0x0D;
|
||||
const CMD_FLASH_BEGIN: u8 = 0x02;
|
||||
const CMD_FLASH_DATA: u8 = 0x03;
|
||||
const CMD_FLASH_END: u8 = 0x04;
|
||||
|
||||
const FLASH_BLOCK_SIZE: u32 = 1024;
|
||||
/// Flash erase granularity — the ROM erases in minimum 4 KB units.
|
||||
/// erase_size in FLASH_BEGIN must be aligned to this boundary.
|
||||
const FLASH_SECTOR_SIZE: u32 = 0x1000;
|
||||
const INITIAL_BAUD: u32 = 115200;
|
||||
const FLASH_BAUD: u32 = 460800;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn xor_checksum(data: &[u8]) -> u32 {
|
||||
let mut chk: u8 = 0xEF;
|
||||
for &b in data {
|
||||
chk ^= b;
|
||||
}
|
||||
chk as u32
|
||||
}
|
||||
|
||||
/// Build a bootloader command packet (before SLIP encoding).
|
||||
/// Format: [0x00][cmd][size:u16 LE][checksum:u32 LE][data...]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_command(cmd: u8, data: &[u8], checksum: u32) -> Vec<u8> {
|
||||
let size = data.len() as u16;
|
||||
let mut pkt = Vec::with_capacity(8 + data.len());
|
||||
pkt.push(0x00); // direction: command
|
||||
pkt.push(cmd);
|
||||
pkt.push((size & 0xFF) as u8);
|
||||
pkt.push((size >> 8) as u8);
|
||||
pkt.push((checksum & 0xFF) as u8);
|
||||
pkt.push(((checksum >> 8) & 0xFF) as u8);
|
||||
pkt.push(((checksum >> 16) & 0xFF) as u8);
|
||||
pkt.push(((checksum >> 24) & 0xFF) as u8);
|
||||
pkt.extend_from_slice(data);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Extract complete SLIP frames from a byte buffer.
|
||||
/// Returns (frames, remaining_bytes_not_consumed).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn extract_slip_frames(raw: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut frames = Vec::new();
|
||||
let mut in_frame = false;
|
||||
let mut current = Vec::new();
|
||||
|
||||
for &byte in raw {
|
||||
if byte == SLIP_END {
|
||||
if in_frame && !current.is_empty() {
|
||||
// End of frame
|
||||
frames.push(current.clone());
|
||||
current.clear();
|
||||
in_frame = false;
|
||||
} else {
|
||||
// Start of frame (or consecutive 0xC0)
|
||||
in_frame = true;
|
||||
current.clear();
|
||||
}
|
||||
} else if in_frame {
|
||||
current.push(byte);
|
||||
}
|
||||
// If !in_frame and byte != SLIP_END, it's garbage — skip
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
/// Send a command and receive a valid response.
|
||||
/// Handles boot log garbage and multiple SYNC responses.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn send_command(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
cmd: u8,
|
||||
data: &[u8],
|
||||
checksum: u32,
|
||||
timeout_ms: u64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let pkt = build_command(cmd, data, checksum);
|
||||
let frame = slip_encode(&pkt);
|
||||
|
||||
port.write_all(&frame)
|
||||
.map_err(|e| format!("Write error: {}", e))?;
|
||||
port.flush()
|
||||
.map_err(|e| format!("Flush error: {}", e))?;
|
||||
|
||||
// Read bytes and extract SLIP frames, looking for a valid response
|
||||
let mut raw = Vec::new();
|
||||
let mut buf = [0u8; 512];
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
loop {
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed > timeout {
|
||||
let got = if raw.is_empty() {
|
||||
"nothing".to_string()
|
||||
} else {
|
||||
format!("{} raw bytes, no valid response", raw.len())
|
||||
};
|
||||
return Err(format!("Response timeout (got {})", got));
|
||||
}
|
||||
|
||||
let read_result = port.read(&mut buf);
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
raw.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a valid response in accumulated data
|
||||
let frames = extract_slip_frames(&raw);
|
||||
for slip_data in &frames {
|
||||
let decoded = slip_decode(slip_data);
|
||||
|
||||
if decoded.len() < 8 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let direction = decoded[0];
|
||||
let resp_cmd = decoded[1];
|
||||
|
||||
if direction != 0x01 || resp_cmd != cmd {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ROM bootloader status is at offset 8 (right after 8-byte header)
|
||||
// Format: [dir][cmd][size:u16][value:u32][status][error][pad][pad]
|
||||
if decoded.len() >= 10 {
|
||||
let status = decoded[8];
|
||||
let error = decoded[9];
|
||||
if status != 0 {
|
||||
return Err(format!("Bootloader error: cmd=0x{:02X} status={}, error={} (0x{:02X})",
|
||||
cmd, status, error, error));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Bootloader entry ====================
|
||||
|
||||
/// Toggle DTR/RTS to reset ESP32 into bootloader mode.
|
||||
/// Standard auto-reset circuit: DTR→EN, RTS→GPIO0.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn enter_bootloader(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
// Hold GPIO0 low (RTS=true) while pulsing EN (DTR)
|
||||
port.write_data_terminal_ready(false)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
port.write_request_to_send(true)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
// Release EN (DTR=true) while keeping GPIO0 low
|
||||
port.write_data_terminal_ready(true)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
port.write_request_to_send(false)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Release all
|
||||
port.write_data_terminal_ready(false)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
|
||||
// Wait for ROM to boot and print its banner before we drain it.
|
||||
// esptool uses DEFAULT_RESET_DELAY = 500 ms here.
|
||||
// 200 ms is too short — the ROM isn't ready to accept SYNC yet,
|
||||
// causing the first SYNC attempts to fail or receive garbage.
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Drain any boot message
|
||||
let _ = port.clear(serialport::ClearBuffer::All);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== High-level commands ====================
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn sync(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
// SYNC payload: [0x07, 0x07, 0x12, 0x20] + 32 x 0x55
|
||||
let mut payload = vec![0x07, 0x07, 0x12, 0x20];
|
||||
payload.extend_from_slice(&[0x55; 32]);
|
||||
|
||||
for attempt in 0..10 {
|
||||
let result = send_command(port, CMD_SYNC, &payload, 0, 500);
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) if attempt < 9 => {
|
||||
// Drain any pending data before retry
|
||||
let _ = port.clear(serialport::ClearBuffer::Input);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => return Err(format!("SYNC failed after 10 attempts: {}", e)),
|
||||
}
|
||||
}
|
||||
Err("SYNC failed".into())
|
||||
}
|
||||
|
||||
/// Tell the bootloader to switch to a faster baud rate, then reconnect.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn change_baudrate(port: &mut Box<dyn SerialPort>, new_baud: u32) -> Result<(), String> {
|
||||
// Payload: [new_baud:u32 LE][old_baud:u32 LE] (old_baud=0 means "current")
|
||||
let mut payload = Vec::with_capacity(8);
|
||||
payload.extend_from_slice(&new_baud.to_le_bytes());
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
|
||||
send_command(port, CMD_CHANGE_BAUDRATE, &payload, 0, 3000)?;
|
||||
|
||||
// Switch host side to new baud
|
||||
port.set_baud_rate(new_baud)
|
||||
.map_err(|e| format!("Set baud error: {}", e))?;
|
||||
|
||||
// Small delay for baud switch to take effect
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let _ = port.clear(serialport::ClearBuffer::All);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spi_attach(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
let payload = [0u8; 8];
|
||||
send_command(port, CMD_SPI_ATTACH, &payload, 0, 3000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_begin(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
offset: u32,
|
||||
total_size: u32,
|
||||
block_size: u32,
|
||||
) -> Result<(), String> {
|
||||
let num_blocks = (total_size + block_size - 1) / block_size;
|
||||
|
||||
// erase_size must be rounded up to flash sector boundary (4 KB).
|
||||
// Passing total_size directly causes the ROM to compute the wrong
|
||||
// sector count — the last sector is never erased, writing into
|
||||
// 0xFF-filled space and producing "invalid segment length 0xffffffff".
|
||||
// This matches esptool's get_erase_size(offset, size) logic.
|
||||
let erase_size = (total_size + FLASH_SECTOR_SIZE - 1) & !(FLASH_SECTOR_SIZE - 1);
|
||||
|
||||
let mut payload = Vec::with_capacity(20);
|
||||
// erase_size (sector-aligned, not raw file size)
|
||||
payload.extend_from_slice(&erase_size.to_le_bytes());
|
||||
// num_blocks
|
||||
payload.extend_from_slice(&num_blocks.to_le_bytes());
|
||||
// block_size
|
||||
payload.extend_from_slice(&block_size.to_le_bytes());
|
||||
// offset
|
||||
payload.extend_from_slice(&offset.to_le_bytes());
|
||||
// encrypted (ESP32-S3 requires this 5th field — 0 = not encrypted)
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
|
||||
// FLASH_BEGIN can take a while (flash erase) — long timeout
|
||||
send_command(port, CMD_FLASH_BEGIN, &payload, 0, 30_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_data(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
seq: u32,
|
||||
data: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let data_len = data.len() as u32;
|
||||
|
||||
let mut payload = Vec::with_capacity(16 + data.len());
|
||||
// data length
|
||||
payload.extend_from_slice(&data_len.to_le_bytes());
|
||||
// sequence number
|
||||
payload.extend_from_slice(&seq.to_le_bytes());
|
||||
// reserved (2 x u32)
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
// data
|
||||
payload.extend_from_slice(data);
|
||||
|
||||
let checksum = xor_checksum(data);
|
||||
send_command(port, CMD_FLASH_DATA, &payload, checksum, 10_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_end(port: &mut Box<dyn SerialPort>, reboot: bool) -> Result<(), String> {
|
||||
let flag: u32 = if reboot { 0 } else { 1 };
|
||||
let payload = flag.to_le_bytes();
|
||||
// FLASH_END might not get a response if device reboots
|
||||
let _ = send_command(port, CMD_FLASH_END, &payload, 0, 2000);
|
||||
|
||||
if reboot {
|
||||
// Hard reset: toggle RTS to pulse EN pin (like esptool --after hard_reset)
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
port.write_request_to_send(true)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
port.write_request_to_send(false)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Main entry point ====================
|
||||
|
||||
/// Flash firmware to ESP32 via programming port (CH340/CP2102).
|
||||
/// Sends progress updates via the channel as (progress_0_to_1, status_message).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn flash_firmware(
|
||||
port_name: &str,
|
||||
firmware: &[u8],
|
||||
offset: u32,
|
||||
tx: &mpsc::Sender<FlashProgress>,
|
||||
) -> Result<(), String> {
|
||||
let send_progress = |progress: f32, msg: String| {
|
||||
let _ = tx.send(FlashProgress::OtaProgress(progress, msg));
|
||||
};
|
||||
|
||||
send_progress(0.0, "Opening port...".into());
|
||||
|
||||
let builder = serialport::new(port_name, INITIAL_BAUD);
|
||||
let builder_timeout = builder.timeout(Duration::from_millis(500));
|
||||
let mut port = builder_timeout.open()
|
||||
.map_err(|e| format!("Cannot open {}: {}", port_name, e))?;
|
||||
|
||||
// Step 1: Enter bootloader
|
||||
send_progress(0.0, "Resetting into bootloader...".into());
|
||||
enter_bootloader(&mut port)?;
|
||||
|
||||
// Step 2: Sync at 115200
|
||||
send_progress(0.0, "Syncing with bootloader...".into());
|
||||
sync(&mut port)?;
|
||||
send_progress(0.02, "Bootloader sync OK".into());
|
||||
|
||||
// Step 3: Switch to 460800 baud for faster flashing
|
||||
send_progress(0.03, format!("Switching to {} baud...", FLASH_BAUD));
|
||||
change_baudrate(&mut port, FLASH_BAUD)?;
|
||||
send_progress(0.04, format!("Baud: {}", FLASH_BAUD));
|
||||
|
||||
// Step 4: SPI attach
|
||||
send_progress(0.05, "Attaching SPI flash...".into());
|
||||
spi_attach(&mut port)?;
|
||||
|
||||
// Step 5: Flash begin (this erases the flash — can take several seconds)
|
||||
let total_size = firmware.len() as u32;
|
||||
let num_blocks = (total_size + FLASH_BLOCK_SIZE - 1) / FLASH_BLOCK_SIZE;
|
||||
send_progress(0.05, format!("Erasing flash ({} KB)...", total_size / 1024));
|
||||
flash_begin(&mut port, offset, total_size, FLASH_BLOCK_SIZE)?;
|
||||
send_progress(0.10, "Flash erased, writing...".into());
|
||||
|
||||
// Step 6: Flash data blocks
|
||||
for (i, chunk) in firmware.chunks(FLASH_BLOCK_SIZE as usize).enumerate() {
|
||||
// Pad last block to block_size
|
||||
let mut block = chunk.to_vec();
|
||||
let pad_needed = FLASH_BLOCK_SIZE as usize - block.len();
|
||||
if pad_needed > 0 {
|
||||
block.extend(std::iter::repeat(0xFF).take(pad_needed));
|
||||
}
|
||||
|
||||
flash_data(&mut port, i as u32, &block)?;
|
||||
|
||||
let blocks_done = (i + 1) as f32;
|
||||
let total_blocks = num_blocks as f32;
|
||||
let progress = 0.10 + 0.85 * (blocks_done / total_blocks);
|
||||
let msg = format!("Writing block {}/{} ({} KB / {} KB)",
|
||||
i + 1, num_blocks,
|
||||
((i + 1) as u32 * FLASH_BLOCK_SIZE).min(total_size) / 1024,
|
||||
total_size / 1024);
|
||||
send_progress(progress, msg);
|
||||
}
|
||||
|
||||
// Step 7: Flash end + reboot
|
||||
send_progress(0.97, "Finalizing...".into());
|
||||
flash_end(&mut port, true)?;
|
||||
|
||||
send_progress(1.0, format!("Flash OK — {} KB written at 0x{:X}", total_size / 1024, offset));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slip_encode_no_special() {
|
||||
let data = vec![0x01, 0x02, 0x03];
|
||||
let encoded = slip_encode(&data);
|
||||
assert_eq!(encoded, vec![0xC0, 0x01, 0x02, 0x03, 0xC0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slip_encode_with_end_byte() {
|
||||
let data = vec![0x01, 0xC0, 0x03];
|
||||
let encoded = slip_encode(&data);
|
||||
assert_eq!(encoded, vec![0xC0, 0x01, 0xDB, 0xDC, 0x03, 0xC0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slip_encode_with_esc_byte() {
|
||||
let data = vec![0x01, 0xDB, 0x03];
|
||||
let encoded = slip_encode(&data);
|
||||
assert_eq!(encoded, vec![0xC0, 0x01, 0xDB, 0xDD, 0x03, 0xC0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slip_roundtrip() {
|
||||
let original = vec![0xC0, 0xDB, 0x00, 0xFF, 0xC0];
|
||||
let encoded = slip_encode(&original);
|
||||
let decoded = slip_decode(&encoded);
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_checksum_basic() {
|
||||
let data = vec![0x01, 0x02, 0x03];
|
||||
let chk = xor_checksum(&data);
|
||||
let expected = 0xEF ^ 0x01 ^ 0x02 ^ 0x03;
|
||||
assert_eq!(chk, expected as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_checksum_empty() {
|
||||
let chk = xor_checksum(&[]);
|
||||
assert_eq!(chk, 0xEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_command_format() {
|
||||
let data = vec![0xAA, 0xBB];
|
||||
let pkt = build_command(0x08, &data, 0x12345678);
|
||||
assert_eq!(pkt[0], 0x00); // direction
|
||||
assert_eq!(pkt[1], 0x08); // command
|
||||
assert_eq!(pkt[2], 0x02); // size low
|
||||
assert_eq!(pkt[3], 0x00); // size high
|
||||
assert_eq!(pkt[4], 0x78); // checksum byte 0
|
||||
assert_eq!(pkt[5], 0x56); // checksum byte 1
|
||||
assert_eq!(pkt[6], 0x34); // checksum byte 2
|
||||
assert_eq!(pkt[7], 0x12); // checksum byte 3
|
||||
assert_eq!(pkt[8], 0xAA); // data
|
||||
assert_eq!(pkt[9], 0xBB);
|
||||
}
|
||||
}
|
||||
390
src/logic/keycode.rs
Normal file
390
src/logic/keycode.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
/// Decode a raw 16-bit keycode into a human-readable string.
|
||||
///
|
||||
/// Covers all KaSe firmware keycode ranges: HID basic keys, layer switches,
|
||||
/// macros, Bluetooth, one-shot, mod-tap, layer-tap, tap-dance, and more.
|
||||
pub fn decode_keycode(raw: u16) -> String {
|
||||
// --- HID basic keycodes 0x00..=0xE7 ---
|
||||
if raw <= 0x00E7 {
|
||||
return hid_key_name(raw as u8);
|
||||
}
|
||||
|
||||
// --- MO (Momentary Layer): 0x0100..=0x0A00, low byte == 0 ---
|
||||
if raw >= 0x0100 && raw <= 0x0A00 && (raw & 0xFF) == 0 {
|
||||
let layer = (raw >> 8) - 1;
|
||||
return format!("MO {layer}");
|
||||
}
|
||||
|
||||
// --- TO (Toggle Layer): 0x0B00..=0x1400, low byte == 0 ---
|
||||
if raw >= 0x0B00 && raw <= 0x1400 && (raw & 0xFF) == 0 {
|
||||
let layer = (raw >> 8) - 0x0B;
|
||||
return format!("TO {layer}");
|
||||
}
|
||||
|
||||
// --- MACRO: 0x1500..=0x2800, low byte == 0 ---
|
||||
if raw >= 0x1500 && raw <= 0x2800 && (raw & 0xFF) == 0 {
|
||||
let idx = (raw >> 8) - 0x14;
|
||||
return format!("M{idx}");
|
||||
}
|
||||
|
||||
// --- BT keycodes ---
|
||||
match raw {
|
||||
0x2900 => return "BT Next".into(),
|
||||
0x2A00 => return "BT Prev".into(),
|
||||
0x2B00 => return "BT Pair".into(),
|
||||
0x2C00 => return "BT Disc".into(),
|
||||
0x2E00 => return "USB/BT".into(),
|
||||
0x2F00 => return "BT On/Off".into(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// --- OSM (One-Shot Mod): 0x3000..=0x30FF ---
|
||||
if raw >= 0x3000 && raw <= 0x30FF {
|
||||
let mods = (raw & 0xFF) as u8;
|
||||
return format!("OSM {}", mod_name(mods));
|
||||
}
|
||||
|
||||
// --- OSL (One-Shot Layer): 0x3100..=0x310F ---
|
||||
if raw >= 0x3100 && raw <= 0x310F {
|
||||
let layer = raw & 0x0F;
|
||||
return format!("OSL {layer}");
|
||||
}
|
||||
|
||||
// --- Fixed special codes ---
|
||||
match raw {
|
||||
0x3200 => return "Caps Word".into(),
|
||||
0x3300 => return "Repeat".into(),
|
||||
0x3400 => return "Leader".into(),
|
||||
0x3500 => return "Feed".into(),
|
||||
0x3600 => return "Play".into(),
|
||||
0x3700 => return "Sleep".into(),
|
||||
0x3800 => return "Meds".into(),
|
||||
0x3900 => return "GEsc".into(),
|
||||
0x3A00 => return "Layer Lock".into(),
|
||||
0x3C00 => return "AS Toggle".into(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// --- KO (Key Override) slots: 0x3D00..=0x3DFF ---
|
||||
if raw >= 0x3D00 && raw <= 0x3DFF {
|
||||
let slot = raw & 0xFF;
|
||||
return format!("KO {slot}");
|
||||
}
|
||||
|
||||
// --- LT (Layer-Tap): 0x4000..=0x4FFF ---
|
||||
// layout: 0x4LKK where L = layer (0..F), KK = HID keycode
|
||||
if raw >= 0x4000 && raw <= 0x4FFF {
|
||||
let layer = (raw >> 8) & 0x0F;
|
||||
let kc = (raw & 0xFF) as u8;
|
||||
return format!("LT {} {}", layer, hid_key_name(kc));
|
||||
}
|
||||
|
||||
// --- MT (Mod-Tap): 0x5000..=0x5FFF ---
|
||||
// layout: 0x5MKK where M = mod nibble (4 bits), KK = HID keycode
|
||||
if raw >= 0x5000 && raw <= 0x5FFF {
|
||||
let mods = ((raw >> 8) & 0x0F) as u8;
|
||||
let kc = (raw & 0xFF) as u8;
|
||||
return format!("MT {} {}", mod_name(mods), hid_key_name(kc));
|
||||
}
|
||||
|
||||
// --- TD (Tap Dance): 0x6000..=0x6FFF ---
|
||||
if raw >= 0x6000 && raw <= 0x6FFF {
|
||||
let index = (raw >> 8) & 0x0F;
|
||||
return format!("TD {index}");
|
||||
}
|
||||
|
||||
// --- Unknown ---
|
||||
format!("0x{raw:04X}")
|
||||
}
|
||||
|
||||
/// Decode a modifier bitmask into a human-readable string.
|
||||
///
|
||||
/// Bits: 0x01=Ctrl, 0x02=Shift, 0x04=Alt, 0x08=GUI,
|
||||
/// 0x10=RCtrl, 0x20=RShift, 0x40=RAlt, 0x80=RGUI.
|
||||
/// Multiple modifiers are joined with "+".
|
||||
pub fn mod_name(mod_mask: u8) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if mod_mask & 0x01 != 0 { parts.push("Ctrl"); }
|
||||
if mod_mask & 0x02 != 0 { parts.push("Shift"); }
|
||||
if mod_mask & 0x04 != 0 { parts.push("Alt"); }
|
||||
if mod_mask & 0x08 != 0 { parts.push("GUI"); }
|
||||
if mod_mask & 0x10 != 0 { parts.push("RCtrl"); }
|
||||
if mod_mask & 0x20 != 0 { parts.push("RShift"); }
|
||||
if mod_mask & 0x40 != 0 { parts.push("RAlt"); }
|
||||
if mod_mask & 0x80 != 0 { parts.push("RGUI"); }
|
||||
if parts.is_empty() {
|
||||
format!("0x{mod_mask:02X}")
|
||||
} else {
|
||||
parts.join("+")
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a single HID usage code (0x00..=0xE7) to a short readable name.
|
||||
pub fn hid_key_name(code: u8) -> String {
|
||||
match code {
|
||||
// No key / transparent
|
||||
0x00 => "None",
|
||||
// 0x01 = ErrorRollOver, 0x02 = POSTFail, 0x03 = ErrorUndefined (not user-facing)
|
||||
0x01 => "ErrRollOver",
|
||||
0x02 => "POSTFail",
|
||||
0x03 => "ErrUndef",
|
||||
|
||||
// Letters
|
||||
0x04 => "A",
|
||||
0x05 => "B",
|
||||
0x06 => "C",
|
||||
0x07 => "D",
|
||||
0x08 => "E",
|
||||
0x09 => "F",
|
||||
0x0A => "G",
|
||||
0x0B => "H",
|
||||
0x0C => "I",
|
||||
0x0D => "J",
|
||||
0x0E => "K",
|
||||
0x0F => "L",
|
||||
0x10 => "M",
|
||||
0x11 => "N",
|
||||
0x12 => "O",
|
||||
0x13 => "P",
|
||||
0x14 => "Q",
|
||||
0x15 => "R",
|
||||
0x16 => "S",
|
||||
0x17 => "T",
|
||||
0x18 => "U",
|
||||
0x19 => "V",
|
||||
0x1A => "W",
|
||||
0x1B => "X",
|
||||
0x1C => "Y",
|
||||
0x1D => "Z",
|
||||
|
||||
// Number row
|
||||
0x1E => "1",
|
||||
0x1F => "2",
|
||||
0x20 => "3",
|
||||
0x21 => "4",
|
||||
0x22 => "5",
|
||||
0x23 => "6",
|
||||
0x24 => "7",
|
||||
0x25 => "8",
|
||||
0x26 => "9",
|
||||
0x27 => "0",
|
||||
|
||||
// Common control keys
|
||||
0x28 => "Enter",
|
||||
0x29 => "Esc",
|
||||
0x2A => "Backspace",
|
||||
0x2B => "Tab",
|
||||
0x2C => "Space",
|
||||
|
||||
// Punctuation / symbols
|
||||
0x2D => "-",
|
||||
0x2E => "=",
|
||||
0x2F => "[",
|
||||
0x30 => "]",
|
||||
0x31 => "\\",
|
||||
0x32 => "Europe1",
|
||||
0x33 => ";",
|
||||
0x34 => "'",
|
||||
0x35 => "`",
|
||||
0x36 => ",",
|
||||
0x37 => ".",
|
||||
0x38 => "/",
|
||||
|
||||
// Caps Lock
|
||||
0x39 => "Caps Lock",
|
||||
|
||||
// Function keys
|
||||
0x3A => "F1",
|
||||
0x3B => "F2",
|
||||
0x3C => "F3",
|
||||
0x3D => "F4",
|
||||
0x3E => "F5",
|
||||
0x3F => "F6",
|
||||
0x40 => "F7",
|
||||
0x41 => "F8",
|
||||
0x42 => "F9",
|
||||
0x43 => "F10",
|
||||
0x44 => "F11",
|
||||
0x45 => "F12",
|
||||
|
||||
// Navigation / editing cluster
|
||||
0x46 => "PrtSc",
|
||||
0x47 => "ScrLk",
|
||||
0x48 => "Pause",
|
||||
0x49 => "Ins",
|
||||
0x4A => "Home",
|
||||
0x4B => "PgUp",
|
||||
0x4C => "Del",
|
||||
0x4D => "End",
|
||||
0x4E => "PgDn",
|
||||
|
||||
// Arrow keys
|
||||
0x4F => "Right",
|
||||
0x50 => "Left",
|
||||
0x51 => "Down",
|
||||
0x52 => "Up",
|
||||
|
||||
// Keypad
|
||||
0x53 => "NumLk",
|
||||
0x54 => "Num /",
|
||||
0x55 => "Num *",
|
||||
0x56 => "Num -",
|
||||
0x57 => "Num +",
|
||||
0x58 => "Num Enter",
|
||||
0x59 => "Num 1",
|
||||
0x5A => "Num 2",
|
||||
0x5B => "Num 3",
|
||||
0x5C => "Num 4",
|
||||
0x5D => "Num 5",
|
||||
0x5E => "Num 6",
|
||||
0x5F => "Num 7",
|
||||
0x60 => "Num 8",
|
||||
0x61 => "Num 9",
|
||||
0x62 => "Num 0",
|
||||
0x63 => "Num .",
|
||||
0x64 => "Europe2",
|
||||
0x65 => "Menu",
|
||||
0x66 => "Power",
|
||||
0x67 => "Num =",
|
||||
|
||||
// F13-F24
|
||||
0x68 => "F13",
|
||||
0x69 => "F14",
|
||||
0x6A => "F15",
|
||||
0x6B => "F16",
|
||||
0x6C => "F17",
|
||||
0x6D => "F18",
|
||||
0x6E => "F19",
|
||||
0x6F => "F20",
|
||||
0x70 => "F21",
|
||||
0x71 => "F22",
|
||||
0x72 => "F23",
|
||||
0x73 => "F24",
|
||||
|
||||
// Misc system keys
|
||||
0x74 => "Execute",
|
||||
0x75 => "Help",
|
||||
0x76 => "Menu2",
|
||||
0x77 => "Select",
|
||||
0x78 => "Stop",
|
||||
0x79 => "Again",
|
||||
0x7A => "Undo",
|
||||
0x7B => "Cut",
|
||||
0x7C => "Copy",
|
||||
0x7D => "Paste",
|
||||
0x7E => "Find",
|
||||
0x7F => "Mute",
|
||||
0x80 => "Vol Up",
|
||||
0x81 => "Vol Down",
|
||||
|
||||
// Locking keys
|
||||
0x82 => "Lock Caps",
|
||||
0x83 => "Lock Num",
|
||||
0x84 => "Lock Scroll",
|
||||
|
||||
// Keypad extras
|
||||
0x85 => "Num ,",
|
||||
0x86 => "Num =2",
|
||||
|
||||
// International / Kanji
|
||||
0x87 => "Kanji1",
|
||||
0x88 => "Kanji2",
|
||||
0x89 => "Kanji3",
|
||||
0x8A => "Kanji4",
|
||||
0x8B => "Kanji5",
|
||||
0x8C => "Kanji6",
|
||||
0x8D => "Kanji7",
|
||||
0x8E => "Kanji8",
|
||||
0x8F => "Kanji9",
|
||||
|
||||
// Language keys
|
||||
0x90 => "Lang1",
|
||||
0x91 => "Lang2",
|
||||
0x92 => "Lang3",
|
||||
0x93 => "Lang4",
|
||||
0x94 => "Lang5",
|
||||
0x95 => "Lang6",
|
||||
0x96 => "Lang7",
|
||||
0x97 => "Lang8",
|
||||
0x98 => "Lang9",
|
||||
|
||||
// Rare system keys
|
||||
0x99 => "Alt Erase",
|
||||
0x9A => "SysReq",
|
||||
0x9B => "Cancel",
|
||||
0x9C => "Clear",
|
||||
0x9D => "Prior",
|
||||
0x9E => "Return",
|
||||
0x9F => "Separator",
|
||||
0xA0 => "Out",
|
||||
0xA1 => "Oper",
|
||||
0xA2 => "Clear Again",
|
||||
0xA3 => "CrSel",
|
||||
0xA4 => "ExSel",
|
||||
|
||||
// 0xA5..=0xAF reserved / not defined in standard HID tables
|
||||
|
||||
// Extended keypad
|
||||
0xB0 => "Num 00",
|
||||
0xB1 => "Num 000",
|
||||
0xB2 => "Thousands Sep",
|
||||
0xB3 => "Decimal Sep",
|
||||
0xB4 => "Currency",
|
||||
0xB5 => "Currency Sub",
|
||||
0xB6 => "Num (",
|
||||
0xB7 => "Num )",
|
||||
0xB8 => "Num {",
|
||||
0xB9 => "Num }",
|
||||
0xBA => "Num Tab",
|
||||
0xBB => "Num Bksp",
|
||||
0xBC => "Num A",
|
||||
0xBD => "Num B",
|
||||
0xBE => "Num C",
|
||||
0xBF => "Num D",
|
||||
0xC0 => "Num E",
|
||||
0xC1 => "Num F",
|
||||
0xC2 => "Num XOR",
|
||||
0xC3 => "Num ^",
|
||||
0xC4 => "Num %",
|
||||
0xC5 => "Num <",
|
||||
0xC6 => "Num >",
|
||||
0xC7 => "Num &",
|
||||
0xC8 => "Num &&",
|
||||
0xC9 => "Num |",
|
||||
0xCA => "Num ||",
|
||||
0xCB => "Num :",
|
||||
0xCC => "Num #",
|
||||
0xCD => "Num Space",
|
||||
0xCE => "Num @",
|
||||
0xCF => "Num !",
|
||||
0xD0 => "Num M Store",
|
||||
0xD1 => "Num M Recall",
|
||||
0xD2 => "Num M Clear",
|
||||
0xD3 => "Num M+",
|
||||
0xD4 => "Num M-",
|
||||
0xD5 => "Num M*",
|
||||
0xD6 => "Num M/",
|
||||
0xD7 => "Num +/-",
|
||||
0xD8 => "Num Clear",
|
||||
0xD9 => "Num ClrEntry",
|
||||
0xDA => "Num Binary",
|
||||
0xDB => "Num Octal",
|
||||
0xDC => "Num Decimal",
|
||||
0xDD => "Num Hex",
|
||||
|
||||
// 0xDE..=0xDF reserved
|
||||
|
||||
// Modifier keys
|
||||
0xE0 => "LCtrl",
|
||||
0xE1 => "LShift",
|
||||
0xE2 => "LAlt",
|
||||
0xE3 => "LGUI",
|
||||
0xE4 => "RCtrl",
|
||||
0xE5 => "RShift",
|
||||
0xE6 => "RAlt",
|
||||
0xE7 => "RGUI",
|
||||
|
||||
// Anything else in 0x00..=0xFF not covered above
|
||||
_ => return format!("0x{code:02X}"),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
286
src/logic/layout.rs
Normal file
286
src/logic/layout.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
use serde_json::Value;
|
||||
|
||||
/// A keycap with computed absolute position.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct KeycapPos {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
pub angle: f32, // degrees
|
||||
}
|
||||
|
||||
const KEY_SIZE: f32 = 50.0;
|
||||
const KEY_GAP: f32 = 4.0;
|
||||
|
||||
/// Parse a layout JSON string into absolute key positions.
|
||||
pub fn parse_json(json: &str) -> Result<Vec<KeycapPos>, String> {
|
||||
let val: Value = serde_json::from_str(json)
|
||||
.map_err(|e| format!("Invalid layout JSON: {}", e))?;
|
||||
let mut keys = Vec::new();
|
||||
walk(&val, 0.0, 0.0, 0.0, &mut keys);
|
||||
if keys.is_empty() {
|
||||
return Err("No keys found in layout".into());
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Default layout embedded at compile time.
|
||||
pub fn default_layout() -> Vec<KeycapPos> {
|
||||
let json = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/default.json"));
|
||||
parse_json(json).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn walk(node: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match node.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
for (key, val) in obj {
|
||||
let key_str = key.as_str();
|
||||
match key_str {
|
||||
"Group" => walk_group(val, ox, oy, parent_angle, out),
|
||||
"Line" => walk_line(val, ox, oy, parent_angle, out),
|
||||
"Keycap" => walk_keycap(val, ox, oy, parent_angle, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_margin(val: &Value) -> (f32, f32, f32, f32) {
|
||||
let as_str = val.as_str();
|
||||
if let Some(s) = as_str {
|
||||
let split = s.split(',');
|
||||
let parts: Vec<f32> = split
|
||||
.filter_map(|p| {
|
||||
let trimmed = p.trim();
|
||||
let parsed = trimmed.parse().ok();
|
||||
parsed
|
||||
})
|
||||
.collect();
|
||||
let has_four_parts = parts.len() == 4;
|
||||
if has_four_parts {
|
||||
return (parts[0], parts[1], parts[2], parts[3]);
|
||||
}
|
||||
}
|
||||
(0.0, 0.0, 0.0, 0.0)
|
||||
}
|
||||
|
||||
fn parse_angle(val: &Value) -> f32 {
|
||||
let rotate_transform = val.get("RotateTransform");
|
||||
let angle_val = rotate_transform
|
||||
.and_then(|rt| rt.get("Angle"));
|
||||
let angle_f64 = angle_val
|
||||
.and_then(|a| a.as_f64());
|
||||
let angle = angle_f64.unwrap_or(0.0) as f32;
|
||||
angle
|
||||
}
|
||||
|
||||
fn walk_group(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let gx = ox + ml;
|
||||
let gy = oy + mt;
|
||||
|
||||
let children_val = obj.get("Children");
|
||||
let children_array = children_val
|
||||
.and_then(|c| c.as_array());
|
||||
if let Some(children) = children_array {
|
||||
let combined_angle = parent_angle + angle;
|
||||
for child in children {
|
||||
walk(child, gx, gy, combined_angle, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_line(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
let total_angle = parent_angle + angle;
|
||||
|
||||
let orientation_val = obj.get("Orientation");
|
||||
let orientation_str = orientation_val
|
||||
.and_then(|o| o.as_str())
|
||||
.unwrap_or("Vertical");
|
||||
let horiz = orientation_str == "Horizontal";
|
||||
|
||||
let lx = ox + ml;
|
||||
let ly = oy + mt;
|
||||
|
||||
let rad = total_angle.to_radians();
|
||||
let cos_a = rad.cos();
|
||||
let sin_a = rad.sin();
|
||||
|
||||
let mut cursor = 0.0f32;
|
||||
|
||||
let children_val = obj.get("Children");
|
||||
let children_array = children_val
|
||||
.and_then(|c| c.as_array());
|
||||
if let Some(children) = children_array {
|
||||
for child in children {
|
||||
let (cx, cy) = if horiz {
|
||||
let x = lx + cursor * cos_a;
|
||||
let y = ly + cursor * sin_a;
|
||||
(x, y)
|
||||
} else {
|
||||
let x = lx - cursor * sin_a;
|
||||
let y = ly + cursor * cos_a;
|
||||
(x, y)
|
||||
};
|
||||
|
||||
let child_size = measure(child, horiz);
|
||||
walk(child, cx, cy, total_angle, out);
|
||||
cursor += child_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure a child's extent along the parent's main axis.
|
||||
fn measure(node: &Value, horiz: bool) -> f32 {
|
||||
let obj = match node.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
|
||||
for (key, val) in obj {
|
||||
let key_str = key.as_str();
|
||||
match key_str {
|
||||
"Keycap" => {
|
||||
let width_val = val.get("Width");
|
||||
let width_f64 = width_val
|
||||
.and_then(|v| v.as_f64());
|
||||
let w = width_f64.unwrap_or(KEY_SIZE as f64) as f32;
|
||||
let extent = if horiz {
|
||||
w + KEY_GAP
|
||||
} else {
|
||||
KEY_SIZE + KEY_GAP
|
||||
};
|
||||
return extent;
|
||||
}
|
||||
"Line" => {
|
||||
let sub = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
let sub_orientation = sub.get("Orientation");
|
||||
let sub_orient_str = sub_orientation
|
||||
.and_then(|o| o.as_str())
|
||||
.unwrap_or("Vertical");
|
||||
let sub_horiz = sub_orient_str == "Horizontal";
|
||||
|
||||
let sub_children_val = sub.get("Children");
|
||||
let sub_children_array = sub_children_val
|
||||
.and_then(|c| c.as_array());
|
||||
let children = sub_children_array
|
||||
.map(|a| a.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
let same_direction = sub_horiz == horiz;
|
||||
let content: f32 = if same_direction {
|
||||
// Same direction: sum
|
||||
children
|
||||
.iter()
|
||||
.map(|c| measure(c, sub_horiz))
|
||||
.sum()
|
||||
} else {
|
||||
// Cross direction: max
|
||||
children
|
||||
.iter()
|
||||
.map(|c| measure(c, horiz))
|
||||
.fold(0.0f32, f32::max)
|
||||
};
|
||||
|
||||
return content;
|
||||
}
|
||||
"Group" => {
|
||||
let sub = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
let sub_children_val = sub.get("Children");
|
||||
let sub_children_array = sub_children_val
|
||||
.and_then(|c| c.as_array());
|
||||
let children = sub_children_array
|
||||
.map(|a| a.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let max_extent = children
|
||||
.iter()
|
||||
.map(|c| measure(c, horiz))
|
||||
.fold(0.0f32, f32::max);
|
||||
return max_extent;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
fn walk_keycap(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let col_val = obj.get("Column");
|
||||
let col_u64 = col_val
|
||||
.and_then(|v| v.as_u64());
|
||||
let col = col_u64.unwrap_or(0) as usize;
|
||||
|
||||
let row_val = obj.get("Row");
|
||||
let row_u64 = row_val
|
||||
.and_then(|v| v.as_u64());
|
||||
let row = row_u64.unwrap_or(0) as usize;
|
||||
|
||||
let width_val = obj.get("Width");
|
||||
let width_f64 = width_val
|
||||
.and_then(|v| v.as_f64());
|
||||
let w = width_f64.unwrap_or(KEY_SIZE as f64) as f32;
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let total_angle = parent_angle + angle;
|
||||
|
||||
out.push(KeycapPos {
|
||||
row,
|
||||
col,
|
||||
x: ox + ml,
|
||||
y: oy + mt,
|
||||
w,
|
||||
h: KEY_SIZE,
|
||||
angle: total_angle,
|
||||
});
|
||||
}
|
||||
339
src/logic/layout_remap.rs
Normal file
339
src/logic/layout_remap.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
/// Remaps HID key names to their visual representation based on the user's
|
||||
/// keyboard layout (language). Ported from the C# `KeyConverter.LayoutOverrides`.
|
||||
|
||||
// Display implementation for the keyboard layout picker in settings.
|
||||
impl std::fmt::Display for KeyboardLayout {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.name())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum KeyboardLayout {
|
||||
Qwerty,
|
||||
Azerty,
|
||||
Qwertz,
|
||||
Dvorak,
|
||||
Colemak,
|
||||
Bepo,
|
||||
QwertyEs,
|
||||
QwertyPt,
|
||||
QwertyIt,
|
||||
QwertyNordic,
|
||||
QwertyBr,
|
||||
QwertyTr,
|
||||
QwertyUk,
|
||||
}
|
||||
|
||||
impl KeyboardLayout {
|
||||
/// All known layout variants.
|
||||
pub fn all() -> &'static [KeyboardLayout] {
|
||||
&[
|
||||
KeyboardLayout::Qwerty,
|
||||
KeyboardLayout::Azerty,
|
||||
KeyboardLayout::Qwertz,
|
||||
KeyboardLayout::Dvorak,
|
||||
KeyboardLayout::Colemak,
|
||||
KeyboardLayout::Bepo,
|
||||
KeyboardLayout::QwertyEs,
|
||||
KeyboardLayout::QwertyPt,
|
||||
KeyboardLayout::QwertyIt,
|
||||
KeyboardLayout::QwertyNordic,
|
||||
KeyboardLayout::QwertyBr,
|
||||
KeyboardLayout::QwertyTr,
|
||||
KeyboardLayout::QwertyUk,
|
||||
]
|
||||
}
|
||||
|
||||
/// Human-readable display name.
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
KeyboardLayout::Qwerty => "QWERTY",
|
||||
KeyboardLayout::Azerty => "AZERTY",
|
||||
KeyboardLayout::Qwertz => "QWERTZ",
|
||||
KeyboardLayout::Dvorak => "DVORAK",
|
||||
KeyboardLayout::Colemak => "COLEMAK",
|
||||
KeyboardLayout::Bepo => "BEPO",
|
||||
KeyboardLayout::QwertyEs => "QWERTY_ES",
|
||||
KeyboardLayout::QwertyPt => "QWERTY_PT",
|
||||
KeyboardLayout::QwertyIt => "QWERTY_IT",
|
||||
KeyboardLayout::QwertyNordic => "QWERTY_NORDIC",
|
||||
KeyboardLayout::QwertyBr => "QWERTY_BR",
|
||||
KeyboardLayout::QwertyTr => "QWERTY_TR",
|
||||
KeyboardLayout::QwertyUk => "QWERTY_UK",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a layout name (case-insensitive). Falls back to `Qwerty`.
|
||||
pub fn from_name(s: &str) -> Self {
|
||||
match s.to_ascii_uppercase().as_str() {
|
||||
"QWERTY" => KeyboardLayout::Qwerty,
|
||||
"AZERTY" => KeyboardLayout::Azerty,
|
||||
"QWERTZ" => KeyboardLayout::Qwertz,
|
||||
"DVORAK" => KeyboardLayout::Dvorak,
|
||||
"COLEMAK" => KeyboardLayout::Colemak,
|
||||
"BEPO" | "BÉPO" => KeyboardLayout::Bepo,
|
||||
"QWERTY_ES" => KeyboardLayout::QwertyEs,
|
||||
"QWERTY_PT" => KeyboardLayout::QwertyPt,
|
||||
"QWERTY_IT" => KeyboardLayout::QwertyIt,
|
||||
"QWERTY_NORDIC" => KeyboardLayout::QwertyNordic,
|
||||
"QWERTY_BR" => KeyboardLayout::QwertyBr,
|
||||
"QWERTY_TR" => KeyboardLayout::QwertyTr,
|
||||
"QWERTY_UK" => KeyboardLayout::QwertyUk,
|
||||
_ => KeyboardLayout::Qwerty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a `layout` and an HID key name (e.g. `"A"`, `"COMMA"`, `"SEMICOLON"`),
|
||||
/// returns the visual label for that key on the given layout, or `None` when no
|
||||
/// override exists (meaning the default / QWERTY label applies).
|
||||
///
|
||||
/// The lookup is **case-insensitive** on `hid_name`.
|
||||
pub fn remap_key_label(layout: &KeyboardLayout, hid_name: &str) -> Option<&'static str> {
|
||||
// Normalise to uppercase for matching.
|
||||
let key = hid_name.to_ascii_uppercase();
|
||||
let key = key.as_str();
|
||||
|
||||
match layout {
|
||||
// QWERTY has no overrides — it *is* the reference layout.
|
||||
KeyboardLayout::Qwerty => None,
|
||||
|
||||
KeyboardLayout::Azerty => match key {
|
||||
"COMMA" | "COMM" | "COMA" => Some(";"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some(","),
|
||||
"PERIOD" | "DOT" => Some(":"),
|
||||
"SLASH" | "SLSH" | "/" => Some("!"),
|
||||
"M" => Some(","),
|
||||
"W" => Some("Z"),
|
||||
"Z" => Some("W"),
|
||||
"Q" => Some("A"),
|
||||
"A" => Some("Q"),
|
||||
"," => Some(";"),
|
||||
"." => Some(":"),
|
||||
";" => Some("M"),
|
||||
"-" | "MINUS" | "MIN" => Some(")"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" | "[" => Some("^"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" | "]" => Some("$"),
|
||||
"BACKSLASH" | "BSLSH" | "\\" => Some("<"),
|
||||
"APOSTROPHE" | "APO" | "QUOT" | "'" => Some("\u{00f9}"), // ù
|
||||
"1" => Some("& 1"),
|
||||
"2" => Some("\u{00e9} 2 ~"), // é 2 ~
|
||||
"3" => Some("\" 3 #"),
|
||||
"4" => Some("' 4 }"),
|
||||
"5" => Some("( 5 ["),
|
||||
"6" => Some("- 6 |"),
|
||||
"7" => Some("\u{00e8} 7 `"), // è 7 `
|
||||
"8" => Some("_ 8 \\"),
|
||||
"9" => Some("\u{00e7} 9 ^"), // ç 9 ^
|
||||
"0" => Some("\u{00e0} 0 @"), // à 0 @
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Qwertz => match key {
|
||||
"Y" => Some("Z"),
|
||||
"Z" => Some("Y"),
|
||||
"MINUS" | "MIN" => Some("\u{00df}"), // ß
|
||||
"EQUAL" | "EQL" => Some("'"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00fc}"), // ü
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f6}"), // ö
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e4}"), // ä
|
||||
"GRAVE" | "GRV" => Some("^"),
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("#"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Dvorak => match key {
|
||||
"Q" => Some("'"),
|
||||
"W" => Some(","),
|
||||
"E" => Some("."),
|
||||
"R" => Some("P"),
|
||||
"T" => Some("Y"),
|
||||
"Y" => Some("F"),
|
||||
"U" => Some("G"),
|
||||
"I" => Some("C"),
|
||||
"O" => Some("R"),
|
||||
"P" => Some("L"),
|
||||
"A" => Some("A"),
|
||||
"S" => Some("O"),
|
||||
"D" => Some("E"),
|
||||
"F" => Some("U"),
|
||||
"G" => Some("I"),
|
||||
"H" => Some("D"),
|
||||
"J" => Some("H"),
|
||||
"K" => Some("T"),
|
||||
"L" => Some("N"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("S"),
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("-"),
|
||||
"Z" => Some(";"),
|
||||
"X" => Some("Q"),
|
||||
"C" => Some("J"),
|
||||
"V" => Some("K"),
|
||||
"B" => Some("X"),
|
||||
"N" => Some("B"),
|
||||
"M" => Some("M"),
|
||||
"COMMA" | "COMM" | "COMA" => Some("W"),
|
||||
"PERIOD" | "DOT" => Some("V"),
|
||||
"SLASH" | "SLSH" => Some("Z"),
|
||||
"MINUS" | "MIN" => Some("["),
|
||||
"EQUAL" | "EQL" => Some("]"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("/"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("="),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Colemak => match key {
|
||||
"E" => Some("F"),
|
||||
"R" => Some("P"),
|
||||
"T" => Some("G"),
|
||||
"Y" => Some("J"),
|
||||
"U" => Some("L"),
|
||||
"I" => Some("U"),
|
||||
"O" => Some("Y"),
|
||||
"P" => Some(";"),
|
||||
"S" => Some("R"),
|
||||
"D" => Some("S"),
|
||||
"F" => Some("T"),
|
||||
"G" => Some("D"),
|
||||
"J" => Some("N"),
|
||||
"K" => Some("E"),
|
||||
"L" => Some("I"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("O"),
|
||||
"N" => Some("K"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Bepo => match key {
|
||||
"Q" => Some("B"),
|
||||
"W" => Some("E"),
|
||||
"E" => Some("P"),
|
||||
"R" => Some("O"),
|
||||
"T" => Some("E"),
|
||||
"Y" => Some("^"),
|
||||
"U" => Some("V"),
|
||||
"I" => Some("D"),
|
||||
"O" => Some("L"),
|
||||
"P" => Some("J"),
|
||||
"A" => Some("A"),
|
||||
"S" => Some("U"),
|
||||
"D" => Some("I"),
|
||||
"F" => Some("E"),
|
||||
"G" => Some(","),
|
||||
"H" => Some("C"),
|
||||
"J" => Some("T"),
|
||||
"K" => Some("S"),
|
||||
"L" => Some("R"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("N"),
|
||||
"Z" => Some("A"),
|
||||
"X" => Some("Y"),
|
||||
"C" => Some("X"),
|
||||
"V" => Some("."),
|
||||
"B" => Some("K"),
|
||||
"N" => Some("'"),
|
||||
"M" => Some("Q"),
|
||||
"COMMA" | "COMM" | "COMA" => Some("G"),
|
||||
"PERIOD" | "DOT" => Some("H"),
|
||||
"SLASH" | "SLSH" => Some("F"),
|
||||
"1" => Some("\""),
|
||||
"2" => Some("<"),
|
||||
"3" => Some(">"),
|
||||
"4" => Some("("),
|
||||
"5" => Some(")"),
|
||||
"6" => Some("@"),
|
||||
"7" => Some("+"),
|
||||
"8" => Some("-"),
|
||||
"9" => Some("/"),
|
||||
"0" => Some("*"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyEs => match key {
|
||||
"MINUS" | "MIN" => Some("'"),
|
||||
"EQUAL" | "EQL" => Some("\u{00a1}"), // ¡
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("`"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f1}"), // ñ
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("'"),
|
||||
"GRAVE" | "GRV" => Some("\u{00ba}"), // º
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("\u{00e7}"), // ç
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyPt => match key {
|
||||
"MINUS" | "MIN" => Some("'"),
|
||||
"EQUAL" | "EQL" => Some("\u{00ab}"), // «
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("+"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("'"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00e7}"), // ç
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00ba}"), // º
|
||||
"GRAVE" | "GRV" => Some("\\"),
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("~"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyIt => match key {
|
||||
"MINUS" | "MIN" => Some("'"),
|
||||
"EQUAL" | "EQL" => Some("\u{00ec}"), // ì
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00e8}"), // è
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f2}"), // ò
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e0}"), // à
|
||||
"GRAVE" | "GRV" => Some("\\"),
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("\u{00f9}"), // ù
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyNordic => match key {
|
||||
"MINUS" | "MIN" => Some("+"),
|
||||
"EQUAL" | "EQL" => Some("'"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00e5}"), // å
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("\u{00a8}"), // ¨
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f6}"), // ö
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e4}"), // ä
|
||||
"GRAVE" | "GRV" => Some("\u{00a7}"), // §
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("'"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyBr => match key {
|
||||
"MINUS" | "MIN" => Some("-"),
|
||||
"EQUAL" | "EQL" => Some("="),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("'"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("["),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00e7}"), // ç
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("~"),
|
||||
"GRAVE" | "GRV" => Some("'"),
|
||||
"SLASH" | "SLSH" => Some(";"),
|
||||
"BACKSLASH" | "BSLSH" => Some("]"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyTr => match key {
|
||||
"MINUS" | "MIN" => Some("*"),
|
||||
"EQUAL" | "EQL" => Some("-"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{011f}"), // ğ
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("\u{00fc}"), // ü
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{015f}"), // ş
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("i"),
|
||||
"GRAVE" | "GRV" => Some("\""),
|
||||
"SLASH" | "SLSH" => Some("."),
|
||||
"BACKSLASH" | "BSLSH" => Some(","),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyUk => match key {
|
||||
"GRAVE" | "GRV" => Some("`"),
|
||||
"MINUS" | "MIN" => Some("-"),
|
||||
"EQUAL" | "EQL" => Some("="),
|
||||
"BACKSLASH" | "BSLSH" => Some("#"),
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("'"),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
19
src/logic/mod.rs
Normal file
19
src/logic/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#[allow(dead_code)]
|
||||
pub mod binary_protocol;
|
||||
#[allow(dead_code)]
|
||||
pub mod flasher;
|
||||
#[allow(dead_code)]
|
||||
pub mod keycode;
|
||||
pub mod layout;
|
||||
#[allow(dead_code)]
|
||||
pub mod layout_remap;
|
||||
#[allow(dead_code)]
|
||||
pub mod parsers;
|
||||
#[allow(dead_code)]
|
||||
pub mod protocol;
|
||||
#[allow(dead_code)]
|
||||
pub mod serial;
|
||||
#[allow(dead_code)]
|
||||
pub mod settings;
|
||||
#[allow(dead_code)]
|
||||
pub mod stats_analyzer;
|
||||
900
src/logic/parsers.rs
Normal file
900
src/logic/parsers.rs
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
/// Parsing functions for firmware text and binary responses.
|
||||
/// Separated for testability.
|
||||
|
||||
/// Keyboard physical dimensions (must match firmware).
|
||||
pub const ROWS: usize = 5;
|
||||
pub const COLS: usize = 13;
|
||||
|
||||
/// Parse "TD0: 04,05,06,29" lines into an array of 8 tap dance slots.
|
||||
/// Each slot has 4 actions: [1-tap, 2-tap, 3-tap, hold].
|
||||
pub fn parse_td_lines(lines: &[String]) -> Vec<[u16; 4]> {
|
||||
let mut result = vec![[0u16; 4]; 8];
|
||||
|
||||
for line in lines {
|
||||
// Only process lines starting with "TD"
|
||||
let starts_with_td = line.starts_with("TD");
|
||||
if !starts_with_td {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the colon separator: "TD0: ..."
|
||||
let colon = match line.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract the index between "TD" and ":"
|
||||
let index_str = &line[2..colon];
|
||||
let idx: usize = match index_str.parse() {
|
||||
Ok(i) if i < 8 => i,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Parse the comma-separated hex values after the colon
|
||||
let after_colon = &line[colon + 1..];
|
||||
let trimmed_values = after_colon.trim();
|
||||
let split_parts = trimmed_values.split(',');
|
||||
let vals: Vec<u16> = split_parts
|
||||
.filter_map(|s| {
|
||||
let trimmed_part = s.trim();
|
||||
u16::from_str_radix(trimmed_part, 16).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// We need exactly 4 values
|
||||
let has_four_values = vals.len() == 4;
|
||||
if has_four_values {
|
||||
result[idx] = [vals[0], vals[1], vals[2], vals[3]];
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse KO (Key Override) lines into arrays of [trigger, mod, result, res_mod].
|
||||
/// Format: "KO0: trigger=2A mod=02 -> result=4C resmod=00"
|
||||
pub fn parse_ko_lines(lines: &[String]) -> Vec<[u8; 4]> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
// Only process lines starting with "KO"
|
||||
let starts_with_ko = line.starts_with("KO");
|
||||
if !starts_with_ko {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Helper: extract hex value after a keyword like "trigger="
|
||||
let parse_hex = |key: &str| -> u8 {
|
||||
let key_position = line.find(key);
|
||||
|
||||
let after_key = match key_position {
|
||||
Some(i) => {
|
||||
let rest = &line[i + key.len()..];
|
||||
let first_token = rest.split_whitespace().next();
|
||||
first_token
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let parsed_value = match after_key {
|
||||
Some(s) => {
|
||||
let without_prefix = s.trim_start_matches("0x");
|
||||
u8::from_str_radix(without_prefix, 16).ok()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
parsed_value.unwrap_or(0)
|
||||
};
|
||||
|
||||
let trigger = parse_hex("trigger=");
|
||||
let modifier = parse_hex("mod=");
|
||||
let result_key = parse_hex("result=");
|
||||
let result_mod = parse_hex("resmod=");
|
||||
|
||||
result.push([trigger, modifier, result_key, result_mod]);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse heatmap lines (KEYSTATS? response).
|
||||
/// Format: "R0: 100 50 30 20 10 5 0 15 25 35 45 55 65"
|
||||
/// Returns (data[5][13], max_value).
|
||||
pub fn parse_heatmap_lines(lines: &[String]) -> (Vec<Vec<u32>>, u32) {
|
||||
let mut data: Vec<Vec<u32>> = vec![vec![0u32; COLS]; ROWS];
|
||||
let mut max = 0u32;
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Only process lines starting with "R"
|
||||
if !trimmed.starts_with('R') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the colon
|
||||
let colon = match trimmed.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract row number between "R" and ":"
|
||||
let row_str = &trimmed[1..colon];
|
||||
let row: usize = match row_str.parse() {
|
||||
Ok(r) if r < ROWS => r,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Parse space-separated values after the colon
|
||||
let values_str = &trimmed[colon + 1..];
|
||||
for (col, token) in values_str.split_whitespace().enumerate() {
|
||||
if col >= COLS {
|
||||
break;
|
||||
}
|
||||
let count: u32 = token.parse().unwrap_or(0);
|
||||
data[row][col] = count;
|
||||
if count > max {
|
||||
max = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(data, max)
|
||||
}
|
||||
|
||||
/// Parsed combo: [index, row1, col1, row2, col2, result_keycode]
|
||||
#[derive(Clone)]
|
||||
pub struct ComboEntry {
|
||||
pub index: u8,
|
||||
pub r1: u8,
|
||||
pub c1: u8,
|
||||
pub r2: u8,
|
||||
pub c2: u8,
|
||||
pub result: u16,
|
||||
}
|
||||
|
||||
/// Parse "COMBO0: r3c3+r3c4=29" lines.
|
||||
pub fn parse_combo_lines(lines: &[String]) -> Vec<ComboEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let starts_with_combo = line.starts_with("COMBO");
|
||||
if !starts_with_combo {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find colon: "COMBO0: ..."
|
||||
let colon = match line.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Index between "COMBO" and ":"
|
||||
let index_str = &line[5..colon];
|
||||
let index: u8 = match index_str.parse() {
|
||||
Ok(i) => i,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// After colon: "r3c3+r3c4=29"
|
||||
let rest = line[colon + 1..].trim();
|
||||
|
||||
// Split by "="
|
||||
let eq_parts: Vec<&str> = rest.split('=').collect();
|
||||
let has_two_parts = eq_parts.len() == 2;
|
||||
if !has_two_parts {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split left side by "+"
|
||||
let key_parts: Vec<&str> = eq_parts[0].split('+').collect();
|
||||
let has_two_keys = key_parts.len() == 2;
|
||||
if !has_two_keys {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse "r3c4" format
|
||||
let pos1 = parse_rc(key_parts[0].trim());
|
||||
let pos2 = parse_rc(key_parts[1].trim());
|
||||
|
||||
let (r1, c1) = match pos1 {
|
||||
Some(rc) => rc,
|
||||
None => continue,
|
||||
};
|
||||
let (r2, c2) = match pos2 {
|
||||
Some(rc) => rc,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Parse result keycode (hex)
|
||||
let result_str = eq_parts[1].trim();
|
||||
let result_code: u16 = match u16::from_str_radix(result_str, 16) {
|
||||
Ok(v) => v,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
result.push(ComboEntry {
|
||||
index,
|
||||
r1,
|
||||
c1,
|
||||
r2,
|
||||
c2,
|
||||
result: result_code,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse "r3c4" into (row, col).
|
||||
fn parse_rc(s: &str) -> Option<(u8, u8)> {
|
||||
let lower = s.to_lowercase();
|
||||
|
||||
let r_pos = lower.find('r');
|
||||
let c_pos = lower.find('c');
|
||||
|
||||
let r_idx = match r_pos {
|
||||
Some(i) => i,
|
||||
None => return None,
|
||||
};
|
||||
let c_idx = match c_pos {
|
||||
Some(i) => i,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let row_str = &lower[r_idx + 1..c_idx];
|
||||
let col_str = &lower[c_idx + 1..];
|
||||
|
||||
let row: u8 = match row_str.parse() {
|
||||
Ok(v) => v,
|
||||
_ => return None,
|
||||
};
|
||||
let col: u8 = match col_str.parse() {
|
||||
Ok(v) => v,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some((row, col))
|
||||
}
|
||||
|
||||
/// Parsed leader sequence entry.
|
||||
#[derive(Clone)]
|
||||
pub struct LeaderEntry {
|
||||
pub index: u8,
|
||||
pub sequence: Vec<u8>, // HID keycodes
|
||||
pub result: u8,
|
||||
pub result_mod: u8,
|
||||
}
|
||||
|
||||
/// Parse "LEADER0: 04,->29+00" lines.
|
||||
pub fn parse_leader_lines(lines: &[String]) -> Vec<LeaderEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let starts_with_leader = line.starts_with("LEADER");
|
||||
if !starts_with_leader {
|
||||
continue;
|
||||
}
|
||||
|
||||
let colon = match line.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let index_str = &line[6..colon];
|
||||
let index: u8 = match index_str.parse() {
|
||||
Ok(i) => i,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// After colon: "04,->29+00"
|
||||
let rest = line[colon + 1..].trim();
|
||||
|
||||
// Split by "->"
|
||||
let arrow_parts: Vec<&str> = rest.split("->").collect();
|
||||
let has_two_parts = arrow_parts.len() == 2;
|
||||
if !has_two_parts {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sequence: comma-separated hex keycodes (trailing comma OK)
|
||||
let seq_str = arrow_parts[0].trim().trim_end_matches(',');
|
||||
let sequence: Vec<u8> = seq_str
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let trimmed = s.trim();
|
||||
u8::from_str_radix(trimmed, 16).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Result: "29+00" = keycode + modifier
|
||||
let result_parts: Vec<&str> = arrow_parts[1].trim().split('+').collect();
|
||||
let has_result = result_parts.len() == 2;
|
||||
if !has_result {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result_key = match u8::from_str_radix(result_parts[0].trim(), 16) {
|
||||
Ok(v) => v,
|
||||
_ => continue,
|
||||
};
|
||||
let result_mod = match u8::from_str_radix(result_parts[1].trim(), 16) {
|
||||
Ok(v) => v,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
result.push(LeaderEntry {
|
||||
index,
|
||||
sequence,
|
||||
result: result_key,
|
||||
result_mod,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// A single macro step: keycode + modifier, or delay.
|
||||
#[derive(Clone)]
|
||||
pub struct MacroStep {
|
||||
pub keycode: u8,
|
||||
pub modifier: u8,
|
||||
}
|
||||
|
||||
impl MacroStep {
|
||||
/// Returns true if this step is a delay (keycode 0xFF).
|
||||
pub fn is_delay(&self) -> bool {
|
||||
self.keycode == 0xFF
|
||||
}
|
||||
|
||||
/// Delay in milliseconds (modifier * 10).
|
||||
pub fn delay_ms(&self) -> u32 {
|
||||
self.modifier as u32 * 10
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed macro entry.
|
||||
#[derive(Clone)]
|
||||
pub struct MacroEntry {
|
||||
pub slot: u8,
|
||||
pub name: String,
|
||||
pub steps: Vec<MacroStep>,
|
||||
}
|
||||
|
||||
/// Parse MACROS? text response.
|
||||
/// Lines can be like:
|
||||
/// "MACRO 0: CopyPaste [06:01,FF:0A,19:01]"
|
||||
/// "M0: name=CopyPaste steps=06:01,FF:0A,19:01"
|
||||
/// or just raw text lines
|
||||
pub fn parse_macro_lines(lines: &[String]) -> Vec<MacroEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
// Try format: "MACRO 0: name [steps]" or "M0: ..."
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip empty or header lines
|
||||
let is_empty = trimmed.is_empty();
|
||||
if is_empty {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to find slot number
|
||||
let has_macro_prefix = trimmed.starts_with("MACRO") || trimmed.starts_with("M");
|
||||
if !has_macro_prefix {
|
||||
continue;
|
||||
}
|
||||
|
||||
let colon = match trimmed.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract slot number from prefix
|
||||
let prefix_end = trimmed[..colon].trim();
|
||||
let digits_start = prefix_end
|
||||
.find(|c: char| c.is_ascii_digit())
|
||||
.unwrap_or(prefix_end.len());
|
||||
let slot_str = &prefix_end[digits_start..];
|
||||
let slot: u8 = match slot_str.trim().parse() {
|
||||
Ok(s) => s,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let after_colon = trimmed[colon + 1..].trim();
|
||||
|
||||
// Try to parse name and steps from brackets: "CopyPaste [06:01,FF:0A,19:01]"
|
||||
let bracket_start = after_colon.find('[');
|
||||
let bracket_end = after_colon.find(']');
|
||||
|
||||
let (name, steps_str) = match (bracket_start, bracket_end) {
|
||||
(Some(bs), Some(be)) => {
|
||||
let name_part = after_colon[..bs].trim().to_string();
|
||||
let steps_part = &after_colon[bs + 1..be];
|
||||
(name_part, steps_part.to_string())
|
||||
}
|
||||
_ => {
|
||||
// Try "name=X steps=Y" format
|
||||
let name_eq = after_colon.find("name=");
|
||||
let steps_eq = after_colon.find("steps=");
|
||||
match (name_eq, steps_eq) {
|
||||
(Some(ni), Some(si)) => {
|
||||
let name_start = ni + 5;
|
||||
let name_end = si;
|
||||
let n = after_colon[name_start..name_end].trim().to_string();
|
||||
let s = after_colon[si + 6..].trim().to_string();
|
||||
(n, s)
|
||||
}
|
||||
_ => {
|
||||
// Just use the whole thing as name, no steps
|
||||
(after_colon.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse steps: "06:01,FF:0A,19:01"
|
||||
let mut steps = Vec::new();
|
||||
let has_steps = !steps_str.is_empty();
|
||||
if has_steps {
|
||||
let step_parts = steps_str.split(',');
|
||||
for part in step_parts {
|
||||
let step_trimmed = part.trim();
|
||||
let kv: Vec<&str> = step_trimmed.split(':').collect();
|
||||
let has_two = kv.len() == 2;
|
||||
if !has_two {
|
||||
continue;
|
||||
}
|
||||
let key_byte = u8::from_str_radix(kv[0].trim(), 16).unwrap_or(0);
|
||||
let mod_byte = u8::from_str_radix(kv[1].trim(), 16).unwrap_or(0);
|
||||
steps.push(MacroStep {
|
||||
keycode: key_byte,
|
||||
modifier: mod_byte,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.push(MacroEntry {
|
||||
slot,
|
||||
name,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary payload parsers (protocol v2)
|
||||
// ---------------------------------------------------------------------------
|
||||
// These functions parse the *payload* bytes extracted from a KR response frame.
|
||||
// They produce the same data types as the text parsers above.
|
||||
|
||||
/// Parse TD_LIST (0x51) binary payload.
|
||||
/// Format: [count:u8] then count entries of [idx:u8][a1:u8][a2:u8][a3:u8][a4:u8].
|
||||
/// Returns Vec<[u16; 4]> with 8 slots (same shape as parse_td_lines).
|
||||
pub fn parse_td_binary(payload: &[u8]) -> Vec<[u16; 4]> {
|
||||
let mut result = vec![[0u16; 4]; 8];
|
||||
|
||||
// Need at least 1 byte for count
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let entry_size = 5; // idx(1) + actions(4)
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
// Bounds check: need 5 bytes for this entry
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_bytes = remaining >= entry_size;
|
||||
if !enough_bytes {
|
||||
break;
|
||||
}
|
||||
|
||||
let idx = payload[offset] as usize;
|
||||
let action1 = payload[offset + 1] as u16;
|
||||
let action2 = payload[offset + 2] as u16;
|
||||
let action3 = payload[offset + 3] as u16;
|
||||
let action4 = payload[offset + 4] as u16;
|
||||
|
||||
let valid_index = idx < 8;
|
||||
if valid_index {
|
||||
result[idx] = [action1, action2, action3, action4];
|
||||
}
|
||||
|
||||
offset += entry_size;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse COMBO_LIST (0x61) binary payload.
|
||||
/// Format: [count:u8] then count entries of [idx:u8][r1:u8][c1:u8][r2:u8][c2:u8][result:u8].
|
||||
pub fn parse_combo_binary(payload: &[u8]) -> Vec<ComboEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let entry_size = 6; // idx + r1 + c1 + r2 + c2 + result
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_bytes = remaining >= entry_size;
|
||||
if !enough_bytes {
|
||||
break;
|
||||
}
|
||||
|
||||
let index = payload[offset];
|
||||
let r1 = payload[offset + 1];
|
||||
let c1 = payload[offset + 2];
|
||||
let r2 = payload[offset + 3];
|
||||
let c2 = payload[offset + 4];
|
||||
let result_code = payload[offset + 5] as u16;
|
||||
|
||||
result.push(ComboEntry {
|
||||
index,
|
||||
r1,
|
||||
c1,
|
||||
r2,
|
||||
c2,
|
||||
result: result_code,
|
||||
});
|
||||
|
||||
offset += entry_size;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse LEADER_LIST (0x71) binary payload.
|
||||
/// Format: [count:u8] then per entry: [idx:u8][seq_len:u8][seq: seq_len bytes][result:u8][result_mod:u8].
|
||||
pub fn parse_leader_binary(payload: &[u8]) -> Vec<LeaderEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
// Need at least idx(1) + seq_len(1)
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_for_header = remaining >= 2;
|
||||
if !enough_for_header {
|
||||
break;
|
||||
}
|
||||
|
||||
let index = payload[offset];
|
||||
let seq_len = payload[offset + 1] as usize;
|
||||
offset += 2;
|
||||
|
||||
// Need seq_len bytes for sequence + 2 bytes for result+result_mod
|
||||
let remaining_after_header = payload.len().saturating_sub(offset);
|
||||
let enough_for_body = remaining_after_header >= seq_len + 2;
|
||||
if !enough_for_body {
|
||||
break;
|
||||
}
|
||||
|
||||
let sequence = payload[offset..offset + seq_len].to_vec();
|
||||
offset += seq_len;
|
||||
|
||||
let result_key = payload[offset];
|
||||
let result_mod = payload[offset + 1];
|
||||
offset += 2;
|
||||
|
||||
result.push(LeaderEntry {
|
||||
index,
|
||||
sequence,
|
||||
result: result_key,
|
||||
result_mod,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse KO_LIST (0x92) binary payload.
|
||||
/// Format: [count:u8] then count entries of [idx:u8][trigger_key:u8][trigger_mod:u8][result_key:u8][result_mod:u8].
|
||||
/// Returns Vec<[u8; 4]> = [trigger_key, trigger_mod, result_key, result_mod] (same as parse_ko_lines).
|
||||
pub fn parse_ko_binary(payload: &[u8]) -> Vec<[u8; 4]> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let entry_size = 5; // idx + trigger_key + trigger_mod + result_key + result_mod
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_bytes = remaining >= entry_size;
|
||||
if !enough_bytes {
|
||||
break;
|
||||
}
|
||||
|
||||
// idx is payload[offset], but we skip it (not stored in output)
|
||||
let trigger_key = payload[offset + 1];
|
||||
let trigger_mod = payload[offset + 2];
|
||||
let result_key = payload[offset + 3];
|
||||
let result_mod = payload[offset + 4];
|
||||
|
||||
result.push([trigger_key, trigger_mod, result_key, result_mod]);
|
||||
|
||||
offset += entry_size;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse BT_QUERY (0x80) binary payload.
|
||||
/// Format: [active_slot:u8][initialized:u8][connected:u8][pairing:u8]
|
||||
/// then 3 slot entries: [slot_idx:u8][valid:u8][addr:6 bytes][name_len:u8][name: name_len bytes]
|
||||
/// Returns Vec<String> of text lines compatible with the UI (same shape as legacy text parsing).
|
||||
pub fn parse_bt_binary(payload: &[u8]) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Need at least 4 bytes for the global state header
|
||||
let enough_for_header = payload.len() >= 4;
|
||||
if !enough_for_header {
|
||||
return lines;
|
||||
}
|
||||
|
||||
let active_slot = payload[0];
|
||||
let initialized = payload[1];
|
||||
let connected = payload[2];
|
||||
let pairing = payload[3];
|
||||
|
||||
let status_line = format!(
|
||||
"BT: slot={} init={} conn={} pairing={}",
|
||||
active_slot, initialized, connected, pairing
|
||||
);
|
||||
lines.push(status_line);
|
||||
|
||||
let mut offset = 4;
|
||||
let slot_count = 3;
|
||||
|
||||
for _ in 0..slot_count {
|
||||
// Each slot: slot_idx(1) + valid(1) + addr(6) + name_len(1) = 9 bytes minimum
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_for_slot_header = remaining >= 9;
|
||||
if !enough_for_slot_header {
|
||||
break;
|
||||
}
|
||||
|
||||
let slot_idx = payload[offset];
|
||||
let valid = payload[offset + 1];
|
||||
let addr_bytes = &payload[offset + 2..offset + 8];
|
||||
let name_len = payload[offset + 8] as usize;
|
||||
offset += 9;
|
||||
|
||||
// Read the name string
|
||||
let remaining_for_name = payload.len().saturating_sub(offset);
|
||||
let enough_for_name = remaining_for_name >= name_len;
|
||||
if !enough_for_name {
|
||||
break;
|
||||
}
|
||||
|
||||
let name_bytes = &payload[offset..offset + name_len];
|
||||
let name = String::from_utf8_lossy(name_bytes).to_string();
|
||||
offset += name_len;
|
||||
|
||||
// Format address as "XX:XX:XX:XX:XX:XX"
|
||||
let addr_str = format!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
addr_bytes[0], addr_bytes[1], addr_bytes[2],
|
||||
addr_bytes[3], addr_bytes[4], addr_bytes[5]
|
||||
);
|
||||
|
||||
let slot_line = format!(
|
||||
"BT slot {}: valid={} addr={} name={}",
|
||||
slot_idx, valid, addr_str, name
|
||||
);
|
||||
lines.push(slot_line);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Parse TAMA_QUERY (0xA0) binary payload (22 bytes fixed).
|
||||
/// Format: [enabled:u8][state:u8][hunger:u16 LE][happiness:u16 LE][energy:u16 LE]
|
||||
/// [health:u16 LE][level:u16 LE][xp:u16 LE][total_keys:u32 LE][max_kpm:u32 LE]
|
||||
/// Returns Vec<String> with one summary line.
|
||||
pub fn parse_tama_binary(payload: &[u8]) -> Vec<String> {
|
||||
let expected_size = 22;
|
||||
let enough_bytes = payload.len() >= expected_size;
|
||||
if !enough_bytes {
|
||||
return vec!["TAMA: invalid payload".to_string()];
|
||||
}
|
||||
|
||||
let enabled = payload[0];
|
||||
let _state = payload[1];
|
||||
|
||||
let hunger = u16::from_le_bytes([payload[2], payload[3]]);
|
||||
let happiness = u16::from_le_bytes([payload[4], payload[5]]);
|
||||
let energy = u16::from_le_bytes([payload[6], payload[7]]);
|
||||
let health = u16::from_le_bytes([payload[8], payload[9]]);
|
||||
let level = u16::from_le_bytes([payload[10], payload[11]]);
|
||||
let _xp = u16::from_le_bytes([payload[12], payload[13]]);
|
||||
let total_keys = u32::from_le_bytes([payload[14], payload[15], payload[16], payload[17]]);
|
||||
let _max_kpm = u32::from_le_bytes([payload[18], payload[19], payload[20], payload[21]]);
|
||||
|
||||
let line = format!(
|
||||
"TAMA: Lv{} hunger={} happy={} energy={} health={} keys={} enabled={}",
|
||||
level, hunger, happiness, energy, health, total_keys, enabled
|
||||
);
|
||||
|
||||
vec![line]
|
||||
}
|
||||
|
||||
/// Parse WPM_QUERY (0x93) binary payload (2 bytes fixed).
|
||||
/// Format: [wpm:u16 LE]
|
||||
pub fn parse_wpm_binary(payload: &[u8]) -> String {
|
||||
let enough_bytes = payload.len() >= 2;
|
||||
if !enough_bytes {
|
||||
return "WPM: 0".to_string();
|
||||
}
|
||||
|
||||
let wpm = u16::from_le_bytes([payload[0], payload[1]]);
|
||||
|
||||
format!("WPM: {}", wpm)
|
||||
}
|
||||
|
||||
/// Parse LIST_MACROS (0x30) binary payload.
|
||||
/// Format: [count:u8] then per entry:
|
||||
/// [idx:u8][keycode:u16 LE][name_len:u8][name: name_len bytes]
|
||||
/// [keys_len:u8][keys: keys_len bytes][step_count:u8][{kc:u8,mod:u8}... step_count*2 bytes]
|
||||
pub fn parse_macros_binary(payload: &[u8]) -> Vec<MacroEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
// Need at least: idx(1) + keycode(2) + name_len(1) = 4
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_for_prefix = remaining >= 4;
|
||||
if !enough_for_prefix {
|
||||
break;
|
||||
}
|
||||
|
||||
let slot = payload[offset];
|
||||
// keycode is stored but not used in MacroEntry (it's the trigger keycode)
|
||||
let _keycode = u16::from_le_bytes([payload[offset + 1], payload[offset + 2]]);
|
||||
let name_len = payload[offset + 3] as usize;
|
||||
offset += 4;
|
||||
|
||||
// Read name
|
||||
let remaining_for_name = payload.len().saturating_sub(offset);
|
||||
let enough_for_name = remaining_for_name >= name_len;
|
||||
if !enough_for_name {
|
||||
break;
|
||||
}
|
||||
|
||||
let name_bytes = &payload[offset..offset + name_len];
|
||||
let name = String::from_utf8_lossy(name_bytes).to_string();
|
||||
offset += name_len;
|
||||
|
||||
// Read keys_len + keys (raw key bytes, skipped for MacroEntry)
|
||||
let remaining_for_keys_len = payload.len().saturating_sub(offset);
|
||||
let enough_for_keys_len = remaining_for_keys_len >= 1;
|
||||
if !enough_for_keys_len {
|
||||
break;
|
||||
}
|
||||
|
||||
let keys_len = payload[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
let remaining_for_keys = payload.len().saturating_sub(offset);
|
||||
let enough_for_keys = remaining_for_keys >= keys_len;
|
||||
if !enough_for_keys {
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip the raw keys bytes
|
||||
offset += keys_len;
|
||||
|
||||
// Read step_count + steps
|
||||
let remaining_for_step_count = payload.len().saturating_sub(offset);
|
||||
let enough_for_step_count = remaining_for_step_count >= 1;
|
||||
if !enough_for_step_count {
|
||||
break;
|
||||
}
|
||||
|
||||
let step_count = payload[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
let steps_byte_size = step_count * 2;
|
||||
let remaining_for_steps = payload.len().saturating_sub(offset);
|
||||
let enough_for_steps = remaining_for_steps >= steps_byte_size;
|
||||
if !enough_for_steps {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut steps = Vec::with_capacity(step_count);
|
||||
for i in 0..step_count {
|
||||
let step_offset = offset + i * 2;
|
||||
let kc = payload[step_offset];
|
||||
let md = payload[step_offset + 1];
|
||||
steps.push(MacroStep {
|
||||
keycode: kc,
|
||||
modifier: md,
|
||||
});
|
||||
}
|
||||
offset += steps_byte_size;
|
||||
|
||||
result.push(MacroEntry {
|
||||
slot,
|
||||
name,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse KEYSTATS_BIN (0x40) binary payload.
|
||||
/// Format: [rows:u8][cols:u8][counts: rows*cols * u32 LE]
|
||||
/// Returns (heatmap_data, max_value) — same shape as parse_heatmap_lines.
|
||||
pub fn parse_keystats_binary(payload: &[u8]) -> (Vec<Vec<u32>>, u32) {
|
||||
// Need at least 2 bytes for rows and cols
|
||||
let enough_for_header = payload.len() >= 2;
|
||||
if !enough_for_header {
|
||||
return (vec![], 0);
|
||||
}
|
||||
|
||||
let rows = payload[0] as usize;
|
||||
let cols = payload[1] as usize;
|
||||
let total_cells = rows * cols;
|
||||
let data_byte_size = total_cells * 4; // each count is u32 LE
|
||||
|
||||
let remaining = payload.len().saturating_sub(2);
|
||||
let enough_for_data = remaining >= data_byte_size;
|
||||
if !enough_for_data {
|
||||
return (vec![], 0);
|
||||
}
|
||||
|
||||
let mut data: Vec<Vec<u32>> = vec![vec![0u32; cols]; rows];
|
||||
let mut max_value = 0u32;
|
||||
let mut offset = 2;
|
||||
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let count = u32::from_le_bytes([
|
||||
payload[offset],
|
||||
payload[offset + 1],
|
||||
payload[offset + 2],
|
||||
payload[offset + 3],
|
||||
]);
|
||||
data[row][col] = count;
|
||||
|
||||
let is_new_max = count > max_value;
|
||||
if is_new_max {
|
||||
max_value = count;
|
||||
}
|
||||
|
||||
offset += 4;
|
||||
}
|
||||
}
|
||||
|
||||
(data, max_value)
|
||||
}
|
||||
65
src/logic/protocol.rs
Normal file
65
src/logic/protocol.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#![allow(dead_code)]
|
||||
/// CDC protocol command helpers for KaSe keyboard firmware.
|
||||
|
||||
// Text-based query commands
|
||||
pub const CMD_TAP_DANCE: &str = "TD?";
|
||||
pub const CMD_COMBOS: &str = "COMBO?";
|
||||
pub const CMD_LEADER: &str = "LEADER?";
|
||||
pub const CMD_KEY_OVERRIDE: &str = "KO?";
|
||||
pub const CMD_BT_STATUS: &str = "BT?";
|
||||
pub const CMD_WPM: &str = "WPM?";
|
||||
pub const CMD_TAMA: &str = "TAMA?";
|
||||
pub const CMD_MACROS_TEXT: &str = "MACROS?";
|
||||
pub const CMD_FEATURES: &str = "FEATURES?";
|
||||
pub const CMD_KEYSTATS: &str = "KEYSTATS?";
|
||||
pub const CMD_BIGRAMS: &str = "BIGRAMS?";
|
||||
|
||||
pub fn cmd_set_key(layer: u8, row: u8, col: u8, keycode: u16) -> String {
|
||||
format!("SETKEY {},{},{},{:04X}", layer, row, col, keycode)
|
||||
}
|
||||
|
||||
pub fn cmd_set_layer_name(layer: u8, name: &str) -> String {
|
||||
format!("LAYOUTNAME{}:{}", layer, name)
|
||||
}
|
||||
|
||||
pub fn cmd_bt_switch(slot: u8) -> String {
|
||||
format!("BT SWITCH {}", slot)
|
||||
}
|
||||
|
||||
pub fn cmd_trilayer(l1: u8, l2: u8, l3: u8) -> String {
|
||||
format!("TRILAYER {},{},{}", l1, l2, l3)
|
||||
}
|
||||
|
||||
pub fn cmd_macroseq(slot: u8, name: &str, steps: &str) -> String {
|
||||
format!("MACROSEQ {};{};{}", slot, name, steps)
|
||||
}
|
||||
|
||||
pub fn cmd_macro_del(slot: u8) -> String {
|
||||
format!("MACRODEL {}", slot)
|
||||
}
|
||||
|
||||
pub fn cmd_comboset(index: u8, r1: u8, c1: u8, r2: u8, c2: u8, result: u8) -> String {
|
||||
format!("COMBOSET {};{},{},{},{},{:02X}", index, r1, c1, r2, c2, result)
|
||||
}
|
||||
|
||||
pub fn cmd_combodel(index: u8) -> String {
|
||||
format!("COMBODEL {}", index)
|
||||
}
|
||||
|
||||
pub fn cmd_koset(index: u8, trig_key: u8, trig_mod: u8, res_key: u8, res_mod: u8) -> String {
|
||||
format!("KOSET {};{:02X},{:02X},{:02X},{:02X}", index, trig_key, trig_mod, res_key, res_mod)
|
||||
}
|
||||
|
||||
pub fn cmd_kodel(index: u8) -> String {
|
||||
format!("KODEL {}", index)
|
||||
}
|
||||
|
||||
pub fn cmd_leaderset(index: u8, sequence: &[u8], result: u8, result_mod: u8) -> String {
|
||||
let seq_hex: Vec<String> = sequence.iter().map(|k| format!("{:02X}", k)).collect();
|
||||
let seq_str = seq_hex.join(",");
|
||||
format!("LEADERSET {};{};{:02X},{:02X}", index, seq_str, result, result_mod)
|
||||
}
|
||||
|
||||
pub fn cmd_leaderdel(index: u8) -> String {
|
||||
format!("LEADERDEL {}", index)
|
||||
}
|
||||
15
src/logic/serial/mod.rs
Normal file
15
src/logic/serial/mod.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/// Serial communication module.
|
||||
/// Dispatches to native (serialport crate) or web (WebSerial API)
|
||||
/// depending on the target architecture.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::*;
|
||||
573
src/logic/serial/native.rs
Normal file
573
src/logic/serial/native.rs
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
use serialport::SerialPort;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::logic::binary_protocol::{self as bp, KrResponse};
|
||||
use crate::logic::parsers::{ROWS, COLS};
|
||||
|
||||
const BAUD_RATE: u32 = 115200;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 300;
|
||||
const QUERY_TIMEOUT_MS: u64 = 800;
|
||||
const BINARY_READ_TIMEOUT_MS: u64 = 1500;
|
||||
const LEGACY_BINARY_SETTLE_MS: u64 = 50;
|
||||
const BINARY_SETTLE_MS: u64 = 30;
|
||||
const JSON_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
pub struct SerialManager {
|
||||
port: Option<Box<dyn SerialPort>>,
|
||||
pub port_name: String,
|
||||
pub connected: bool,
|
||||
pub v2: bool, // true if firmware supports binary protocol v2
|
||||
}
|
||||
|
||||
impl SerialManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
port: None,
|
||||
port_name: String::new(),
|
||||
connected: false,
|
||||
v2: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn list_ports() -> Vec<String> {
|
||||
let available = serialport::available_ports();
|
||||
let ports = available.unwrap_or_default();
|
||||
ports.into_iter().map(|p| p.port_name).collect()
|
||||
}
|
||||
|
||||
/// List only ports that look like ESP32 programming ports (CH340, CP210x, FTDI).
|
||||
pub fn list_prog_ports() -> Vec<String> {
|
||||
const CH340_VID: u16 = 0x1A86;
|
||||
const CP210X_VID: u16 = 0x10C4;
|
||||
const FTDI_VID: u16 = 0x0403;
|
||||
|
||||
let available = serialport::available_ports();
|
||||
let ports = available.unwrap_or_default();
|
||||
ports.into_iter()
|
||||
.filter(|p| {
|
||||
matches!(&p.port_type, serialport::SerialPortType::UsbPort(usb)
|
||||
if usb.vid == CH340_VID || usb.vid == CP210X_VID || usb.vid == FTDI_VID)
|
||||
})
|
||||
.map(|p| p.port_name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn connect(&mut self, port_name: &str) -> Result<(), String> {
|
||||
let builder = serialport::new(port_name, BAUD_RATE);
|
||||
let builder_with_timeout = builder.timeout(Duration::from_millis(CONNECT_TIMEOUT_MS));
|
||||
let open_result = builder_with_timeout.open();
|
||||
let port = open_result.map_err(|e| format!("Failed to open {}: {}", port_name, e))?;
|
||||
|
||||
self.port = Some(port);
|
||||
self.port_name = port_name.to_string();
|
||||
self.connected = true;
|
||||
self.v2 = false;
|
||||
|
||||
// Detect v2: try PING
|
||||
if let Some(p) = self.port.as_mut() {
|
||||
let _ = p.clear(serialport::ClearBuffer::All);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
|
||||
|
||||
let ping_result = self.send_binary(bp::cmd::PING, &[]);
|
||||
if ping_result.is_ok() {
|
||||
self.v2 = true;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn auto_connect(&mut self) -> Result<String, String> {
|
||||
let port_name = Self::find_kase_port()?;
|
||||
self.connect(&port_name)?;
|
||||
Ok(port_name)
|
||||
}
|
||||
|
||||
pub fn find_kase_port() -> Result<String, String> {
|
||||
const TARGET_VID: u16 = 0xCAFE;
|
||||
const TARGET_PID: u16 = 0x4001;
|
||||
|
||||
let available = serialport::available_ports();
|
||||
let ports = available.unwrap_or_default();
|
||||
if ports.is_empty() {
|
||||
return Err("No serial ports found".into());
|
||||
}
|
||||
|
||||
// First pass: check USB VID/PID and product name
|
||||
for port in &ports {
|
||||
let port_type = &port.port_type;
|
||||
match port_type {
|
||||
serialport::SerialPortType::UsbPort(usb) => {
|
||||
let vid_matches = usb.vid == TARGET_VID;
|
||||
let pid_matches = usb.pid == TARGET_PID;
|
||||
if vid_matches && pid_matches {
|
||||
return Ok(port.port_name.clone());
|
||||
}
|
||||
|
||||
match &usb.product {
|
||||
Some(product) => {
|
||||
let is_kase = product.contains("KaSe");
|
||||
let is_kesp = product.contains("KeSp");
|
||||
if is_kase || is_kesp {
|
||||
return Ok(port.port_name.clone());
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass (Linux only): check udevadm info
|
||||
#[cfg(target_os = "linux")]
|
||||
for port in &ports {
|
||||
let udevadm_result = std::process::Command::new("udevadm")
|
||||
.args(["info", "-n", &port.port_name])
|
||||
.output();
|
||||
|
||||
match udevadm_result {
|
||||
Ok(output) => {
|
||||
let stdout_bytes = &output.stdout;
|
||||
let text = String::from_utf8_lossy(stdout_bytes);
|
||||
let has_kase = text.contains("KaSe");
|
||||
let has_kesp = text.contains("KeSp");
|
||||
if has_kase || has_kesp {
|
||||
return Ok(port.port_name.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let scanned_count = ports.len();
|
||||
Err(format!("No KaSe keyboard found ({} port(s) scanned)", scanned_count))
|
||||
}
|
||||
|
||||
pub fn port_mut(&mut self) -> Option<&mut Box<dyn SerialPort>> {
|
||||
self.port.as_mut()
|
||||
}
|
||||
|
||||
pub fn disconnect(&mut self) {
|
||||
self.port = None;
|
||||
self.port_name.clear();
|
||||
self.connected = false;
|
||||
self.v2 = false;
|
||||
}
|
||||
|
||||
// ==================== LOW-LEVEL: ASCII LEGACY ====================
|
||||
|
||||
pub fn send_command(&mut self, cmd: &str) -> Result<(), String> {
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let data = format!("{}\r\n", cmd);
|
||||
let bytes = data.as_bytes();
|
||||
|
||||
let write_result = port.write_all(bytes);
|
||||
write_result.map_err(|e| format!("Write: {}", e))?;
|
||||
|
||||
let flush_result = port.flush();
|
||||
flush_result.map_err(|e| format!("Flush: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn query_command(&mut self, cmd: &str) -> Result<Vec<String>, String> {
|
||||
self.send_command(cmd)?;
|
||||
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let cloned_port = port.try_clone();
|
||||
let port_clone = cloned_port.map_err(|e| e.to_string())?;
|
||||
let mut reader = BufReader::new(port_clone);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let start = Instant::now();
|
||||
let max_wait = Duration::from_millis(QUERY_TIMEOUT_MS);
|
||||
|
||||
loop {
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed > max_wait {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut line = String::new();
|
||||
let read_result = reader.read_line(&mut line);
|
||||
|
||||
match read_result {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim().to_string();
|
||||
let is_terminal = trimmed == "OK" || trimmed == "ERROR";
|
||||
if is_terminal {
|
||||
break;
|
||||
}
|
||||
let is_not_empty = !trimmed.is_empty();
|
||||
if is_not_empty {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn read_raw(&mut self, timeout_ms: u64) -> Result<Vec<u8>, String> {
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut result = Vec::new();
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
let read_result = port.read(&mut buf);
|
||||
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
let received_bytes = &buf[..n];
|
||||
result.extend_from_slice(received_bytes);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
_ => {
|
||||
let got_something = !result.is_empty();
|
||||
if got_something {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Legacy C> binary protocol
|
||||
fn query_legacy_binary(&mut self, cmd: &str) -> Result<(u8, Vec<u8>), String> {
|
||||
self.send_command(cmd)?;
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
|
||||
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
|
||||
|
||||
// Look for the "C>" header in the raw bytes
|
||||
let mut windows = raw.windows(2);
|
||||
let header_search = windows.position(|w| w == b"C>");
|
||||
let pos = header_search.ok_or("No C> header found")?;
|
||||
|
||||
let min_packet_size = pos + 5;
|
||||
if raw.len() < min_packet_size {
|
||||
return Err("Packet too short".into());
|
||||
}
|
||||
|
||||
let cmd_type = raw[pos + 2];
|
||||
let low_byte = raw[pos + 3] as u16;
|
||||
let high_byte = (raw[pos + 4] as u16) << 8;
|
||||
let data_len = low_byte | high_byte;
|
||||
|
||||
let data_start = pos + 5;
|
||||
let data_end = data_start.checked_add(data_len as usize)
|
||||
.ok_or("Data length overflow")?;
|
||||
|
||||
if raw.len() < data_end {
|
||||
return Err(format!("Incomplete: need {}, got {}", data_end, raw.len()));
|
||||
}
|
||||
|
||||
let payload = raw[data_start..data_end].to_vec();
|
||||
Ok((cmd_type, payload))
|
||||
}
|
||||
|
||||
// ==================== LOW-LEVEL: BINARY V2 ====================
|
||||
|
||||
/// Send a KS frame, read KR response.
|
||||
pub fn send_binary(&mut self, cmd_id: u8, payload: &[u8]) -> Result<KrResponse, String> {
|
||||
let frame = bp::ks_frame(cmd_id, payload);
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
|
||||
let write_result = port.write_all(&frame);
|
||||
write_result.map_err(|e| format!("Write: {}", e))?;
|
||||
|
||||
let flush_result = port.flush();
|
||||
flush_result.map_err(|e| format!("Flush: {}", e))?;
|
||||
|
||||
std::thread::sleep(Duration::from_millis(BINARY_SETTLE_MS));
|
||||
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
|
||||
|
||||
let (resp, _remaining) = bp::parse_kr(&raw)?;
|
||||
let firmware_ok = resp.is_ok();
|
||||
if !firmware_ok {
|
||||
let status = resp.status_name();
|
||||
return Err(format!("Firmware error: {}", status));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// ==================== HIGH-LEVEL: AUTO V2/LEGACY ====================
|
||||
|
||||
pub fn get_firmware_version(&mut self) -> Option<String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let binary_result = self.send_binary(bp::cmd::VERSION, &[]);
|
||||
match binary_result {
|
||||
Ok(resp) => {
|
||||
let raw_bytes = &resp.payload;
|
||||
let lossy_string = String::from_utf8_lossy(raw_bytes);
|
||||
let version = lossy_string.to_string();
|
||||
return Some(version);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback
|
||||
let query_result = self.query_command("VERSION?");
|
||||
let lines = match query_result {
|
||||
Ok(l) => l,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let mut line_iter = lines.into_iter();
|
||||
let first_line = line_iter.next();
|
||||
first_line
|
||||
}
|
||||
|
||||
pub fn get_keymap(&mut self, layer: u8) -> Result<Vec<Vec<u16>>, String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let resp = self.send_binary(bp::cmd::KEYMAP_GET, &[layer])?;
|
||||
let keymap = self.parse_keymap_payload(&resp.payload)?;
|
||||
return Ok(keymap);
|
||||
}
|
||||
|
||||
// Legacy
|
||||
let cmd = format!("KEYMAP{}", layer);
|
||||
let (cmd_type, data) = self.query_legacy_binary(&cmd)?;
|
||||
|
||||
if cmd_type != 1 {
|
||||
return Err(format!("Unexpected cmd type: {}", cmd_type));
|
||||
}
|
||||
if data.len() < 2 {
|
||||
return Err("Data too short".into());
|
||||
}
|
||||
|
||||
// skip 2-byte layer index in legacy
|
||||
let data_without_header = &data[2..];
|
||||
self.parse_keymap_payload(data_without_header)
|
||||
}
|
||||
|
||||
fn parse_keymap_payload(&self, data: &[u8]) -> Result<Vec<Vec<u16>>, String> {
|
||||
// v2: [layer:u8][keycodes...] -- skip first byte
|
||||
// legacy: already stripped
|
||||
let expected_with_layer_byte = 1 + ROWS * COLS * 2;
|
||||
let has_layer_byte = data.len() >= expected_with_layer_byte;
|
||||
let offset = if has_layer_byte { 1 } else { 0 };
|
||||
let kc_data = &data[offset..];
|
||||
|
||||
let needed_bytes = ROWS * COLS * 2;
|
||||
if kc_data.len() < needed_bytes {
|
||||
return Err(format!("Keymap data too short: {} bytes (need {})", kc_data.len(), needed_bytes));
|
||||
}
|
||||
|
||||
let mut keymap = Vec::with_capacity(ROWS);
|
||||
|
||||
for row_index in 0..ROWS {
|
||||
let mut row = Vec::with_capacity(COLS);
|
||||
|
||||
for col_index in 0..COLS {
|
||||
let idx = (row_index * COLS + col_index) * 2;
|
||||
let low_byte = kc_data[idx] as u16;
|
||||
let high_byte = (kc_data[idx + 1] as u16) << 8;
|
||||
let keycode = low_byte | high_byte;
|
||||
row.push(keycode);
|
||||
}
|
||||
|
||||
keymap.push(row);
|
||||
}
|
||||
|
||||
Ok(keymap)
|
||||
}
|
||||
|
||||
pub fn get_layer_names(&mut self) -> Result<Vec<String>, String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let resp = self.send_binary(bp::cmd::LIST_LAYOUTS, &[])?;
|
||||
|
||||
let payload = &resp.payload;
|
||||
if payload.is_empty() {
|
||||
return Err("Empty response".into());
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let mut names = Vec::with_capacity(count);
|
||||
let mut i = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
let remaining = payload.len();
|
||||
let need_header = i + 2;
|
||||
if need_header > remaining {
|
||||
break;
|
||||
}
|
||||
|
||||
let _layer_index = payload[i];
|
||||
let name_len = payload[i + 1] as usize;
|
||||
i += 2;
|
||||
|
||||
let need_name = i + name_len;
|
||||
if need_name > payload.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let name_bytes = &payload[i..i + name_len];
|
||||
let name_lossy = String::from_utf8_lossy(name_bytes);
|
||||
let name = name_lossy.to_string();
|
||||
names.push(name);
|
||||
i += name_len;
|
||||
}
|
||||
|
||||
let found_names = !names.is_empty();
|
||||
if found_names {
|
||||
return Ok(names);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback: try C> binary protocol
|
||||
let legacy_result = self.query_legacy_binary("LAYOUTS?");
|
||||
match legacy_result {
|
||||
Ok((cmd_type, data)) => {
|
||||
let is_layout_type = cmd_type == 4;
|
||||
let has_data = !data.is_empty();
|
||||
|
||||
if is_layout_type && has_data {
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
let parts = text.split(';');
|
||||
let non_empty = parts.filter(|s| !s.is_empty());
|
||||
let trimmed_names = non_empty.map(|s| {
|
||||
let long_enough = s.len() > 1;
|
||||
if long_enough {
|
||||
s[1..].to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
});
|
||||
let names: Vec<String> = trimmed_names.collect();
|
||||
|
||||
let found_names = !names.is_empty();
|
||||
if found_names {
|
||||
return Ok(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
// Last resort: raw text
|
||||
self.send_command("LAYOUTS?")?;
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS * 2));
|
||||
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
|
||||
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
let split_by_delimiters = text.split(|c: char| c == ';' || c == '\n');
|
||||
|
||||
let cleaned = split_by_delimiters.map(|s| {
|
||||
let step1 = s.trim();
|
||||
let step2 = step1.trim_matches(|c: char| c.is_control() || c == '"');
|
||||
step2
|
||||
});
|
||||
|
||||
let valid_names = cleaned.filter(|s| {
|
||||
let is_not_empty = !s.is_empty();
|
||||
let is_short_enough = s.len() < 30;
|
||||
let no_header_marker = !s.contains("C>");
|
||||
let not_ok = *s != "OK";
|
||||
is_not_empty && is_short_enough && no_header_marker && not_ok
|
||||
});
|
||||
|
||||
let as_strings = valid_names.map(|s| s.to_string());
|
||||
let names: Vec<String> = as_strings.collect();
|
||||
|
||||
let found_any = !names.is_empty();
|
||||
if found_any {
|
||||
Ok(names)
|
||||
} else {
|
||||
Err("No layer names found".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_layout_json(&mut self) -> Result<String, String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let resp = self.send_binary(bp::cmd::GET_LAYOUT_JSON, &[])?;
|
||||
let has_payload = !resp.payload.is_empty();
|
||||
if has_payload {
|
||||
let raw_bytes = &resp.payload;
|
||||
let lossy_string = String::from_utf8_lossy(raw_bytes);
|
||||
let json = lossy_string.to_string();
|
||||
return Ok(json);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: text brace-counting
|
||||
self.send_command("LAYOUT?")?;
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
|
||||
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let mut result = String::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
let start = Instant::now();
|
||||
let max_wait = Duration::from_secs(JSON_TIMEOUT_SECS);
|
||||
let mut brace_count: i32 = 0;
|
||||
let mut started = false;
|
||||
|
||||
while start.elapsed() < max_wait {
|
||||
let read_result = port.read(&mut buf);
|
||||
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
let received_bytes = &buf[..n];
|
||||
let chunk = String::from_utf8_lossy(received_bytes);
|
||||
|
||||
for ch in chunk.chars() {
|
||||
let is_open_brace = ch == '{';
|
||||
if is_open_brace {
|
||||
started = true;
|
||||
brace_count += 1;
|
||||
}
|
||||
|
||||
if started {
|
||||
result.push(ch);
|
||||
}
|
||||
|
||||
let is_close_brace = ch == '}';
|
||||
if is_close_brace && started {
|
||||
brace_count -= 1;
|
||||
let json_complete = brace_count == 0;
|
||||
if json_complete {
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let got_nothing = result.is_empty();
|
||||
if got_nothing {
|
||||
Err("No JSON".into())
|
||||
} else {
|
||||
Err("Incomplete JSON".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper
|
||||
pub type SharedSerial = Arc<Mutex<SerialManager>>;
|
||||
|
||||
pub fn new_shared() -> SharedSerial {
|
||||
let manager = SerialManager::new();
|
||||
let mutex = Mutex::new(manager);
|
||||
let shared = Arc::new(mutex);
|
||||
shared
|
||||
}
|
||||
118
src/logic/settings.rs
Normal file
118
src/logic/settings.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/// Persistent settings for KaSe Controller.
|
||||
///
|
||||
/// - **Native**: `kase_settings.json` next to the executable.
|
||||
/// - **WASM**: `localStorage` under key `"kase_settings"`.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default = "default_layout")]
|
||||
pub keyboard_layout: String,
|
||||
}
|
||||
|
||||
fn default_layout() -> String {
|
||||
"QWERTY".to_string()
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keyboard_layout: default_layout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Native: JSON file next to executable
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_settings {
|
||||
use super::Settings;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Config file path: next to the executable.
|
||||
fn settings_path() -> PathBuf {
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let parent_dir = exe.parent().unwrap_or(std::path::Path::new("."));
|
||||
parent_dir.join("kase_settings.json")
|
||||
}
|
||||
|
||||
pub fn load() -> Settings {
|
||||
let path = settings_path();
|
||||
let json_content = std::fs::read_to_string(&path).ok();
|
||||
let parsed = json_content.and_then(|s| serde_json::from_str(&s).ok());
|
||||
parsed.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(settings: &Settings) {
|
||||
let path = settings_path();
|
||||
let json_result = serde_json::to_string_pretty(settings);
|
||||
if let Ok(json) = json_result {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WASM: browser localStorage
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web_settings {
|
||||
use super::Settings;
|
||||
|
||||
const STORAGE_KEY: &str = "kase_settings";
|
||||
|
||||
/// Get localStorage. Returns None if not in a browser context.
|
||||
fn get_storage() -> Option<web_sys::Storage> {
|
||||
let window = web_sys::window()?;
|
||||
window.local_storage().ok().flatten()
|
||||
}
|
||||
|
||||
pub fn load() -> Settings {
|
||||
let storage = match get_storage() {
|
||||
Some(s) => s,
|
||||
None => return Settings::default(),
|
||||
};
|
||||
|
||||
let json_option = storage.get_item(STORAGE_KEY).ok().flatten();
|
||||
let parsed = json_option.and_then(|s| serde_json::from_str(&s).ok());
|
||||
parsed.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(settings: &Settings) {
|
||||
let storage = match get_storage() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let json_result = serde_json::to_string(settings);
|
||||
if let Ok(json) = json_result {
|
||||
let _ = storage.set_item(STORAGE_KEY, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public interface
|
||||
// =============================================================================
|
||||
|
||||
/// Load settings from persistent storage.
|
||||
/// Returns `Settings::default()` if none found.
|
||||
pub fn load() -> Settings {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return native_settings::load();
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return web_settings::load();
|
||||
}
|
||||
|
||||
/// Save settings to persistent storage. Fails silently.
|
||||
pub fn save(settings: &Settings) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
native_settings::save(settings);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_settings::save(settings);
|
||||
}
|
||||
335
src/logic/stats_analyzer.rs
Normal file
335
src/logic/stats_analyzer.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/// Stats analysis: hand balance, finger load, row usage, top keys, bigrams.
|
||||
/// Transforms raw heatmap data into structured analysis for the stats tab.
|
||||
|
||||
use super::keycode;
|
||||
use super::parsers::ROWS;
|
||||
|
||||
/// Which hand a column belongs to.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Hand {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Which finger a column belongs to.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Finger {
|
||||
Pinky,
|
||||
Ring,
|
||||
Middle,
|
||||
Index,
|
||||
Thumb,
|
||||
}
|
||||
|
||||
/// Row names for the 5 rows.
|
||||
const ROW_NAMES: [&str; 5] = ["Number", "Upper", "Home", "Lower", "Thumb"];
|
||||
|
||||
/// Finger names (French).
|
||||
const FINGER_NAMES: [&str; 5] = ["Pinky", "Ring", "Middle", "Index", "Thumb"];
|
||||
|
||||
/// Map column index → (Hand, Finger).
|
||||
/// KaSe layout: cols 0-5 = left hand, cols 6 (gap), cols 7-12 = right hand.
|
||||
fn col_to_hand_finger(col: usize) -> (Hand, Finger) {
|
||||
match col {
|
||||
0 => (Hand::Left, Finger::Pinky),
|
||||
1 => (Hand::Left, Finger::Ring),
|
||||
2 => (Hand::Left, Finger::Middle),
|
||||
3 => (Hand::Left, Finger::Index),
|
||||
4 => (Hand::Left, Finger::Index), // inner column, still index
|
||||
5 => (Hand::Left, Finger::Thumb),
|
||||
6 => (Hand::Left, Finger::Thumb), // center / gap
|
||||
7 => (Hand::Right, Finger::Thumb),
|
||||
8 => (Hand::Right, Finger::Index),
|
||||
9 => (Hand::Right, Finger::Index), // inner column
|
||||
10 => (Hand::Right, Finger::Middle),
|
||||
11 => (Hand::Right, Finger::Ring),
|
||||
12 => (Hand::Right, Finger::Pinky),
|
||||
_ => (Hand::Left, Finger::Pinky),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand balance result.
|
||||
#[allow(dead_code)]
|
||||
pub struct HandBalance {
|
||||
pub left_count: u32,
|
||||
pub right_count: u32,
|
||||
pub total: u32,
|
||||
pub left_pct: f32,
|
||||
pub right_pct: f32,
|
||||
}
|
||||
|
||||
/// Finger load for one finger.
|
||||
#[allow(dead_code)]
|
||||
pub struct FingerLoad {
|
||||
pub name: String,
|
||||
pub hand: Hand,
|
||||
pub count: u32,
|
||||
pub pct: f32,
|
||||
}
|
||||
|
||||
/// Row usage for one row.
|
||||
#[allow(dead_code)]
|
||||
pub struct RowUsage {
|
||||
pub name: String,
|
||||
pub row: usize,
|
||||
pub count: u32,
|
||||
pub pct: f32,
|
||||
}
|
||||
|
||||
/// A key in the top keys ranking.
|
||||
#[allow(dead_code)]
|
||||
pub struct TopKey {
|
||||
pub name: String,
|
||||
pub finger: String,
|
||||
pub count: u32,
|
||||
pub pct: f32,
|
||||
}
|
||||
|
||||
/// Compute hand balance from heatmap data.
|
||||
pub fn hand_balance(heatmap: &[Vec<u32>]) -> HandBalance {
|
||||
let mut left: u32 = 0;
|
||||
let mut right: u32 = 0;
|
||||
|
||||
for row in heatmap {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
let (hand, _) = col_to_hand_finger(c);
|
||||
match hand {
|
||||
Hand::Left => left += count,
|
||||
Hand::Right => right += count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = left + right;
|
||||
let left_pct = if total > 0 { left as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let right_pct = if total > 0 { right as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
|
||||
HandBalance { left_count: left, right_count: right, total, left_pct, right_pct }
|
||||
}
|
||||
|
||||
/// Compute finger load (10 fingers: 5 left + 5 right).
|
||||
pub fn finger_load(heatmap: &[Vec<u32>]) -> Vec<FingerLoad> {
|
||||
let mut counts = [[0u32; 5]; 2]; // [hand][finger]
|
||||
|
||||
for row in heatmap {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
let (hand, finger) = col_to_hand_finger(c);
|
||||
let hi = if hand == Hand::Left { 0 } else { 1 };
|
||||
let fi = match finger {
|
||||
Finger::Pinky => 0,
|
||||
Finger::Ring => 1,
|
||||
Finger::Middle => 2,
|
||||
Finger::Index => 3,
|
||||
Finger::Thumb => 4,
|
||||
};
|
||||
counts[hi][fi] += count;
|
||||
}
|
||||
}
|
||||
|
||||
let total: u32 = counts[0].iter().sum::<u32>() + counts[1].iter().sum::<u32>();
|
||||
let mut result = Vec::with_capacity(10);
|
||||
|
||||
// Left hand fingers
|
||||
for fi in 0..5 {
|
||||
let count = counts[0][fi];
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let name = format!("{} L", FINGER_NAMES[fi]);
|
||||
result.push(FingerLoad { name, hand: Hand::Left, count, pct });
|
||||
}
|
||||
|
||||
// Right hand fingers
|
||||
for fi in 0..5 {
|
||||
let count = counts[1][fi];
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let name = format!("{} R", FINGER_NAMES[fi]);
|
||||
result.push(FingerLoad { name, hand: Hand::Right, count, pct });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute row usage.
|
||||
pub fn row_usage(heatmap: &[Vec<u32>]) -> Vec<RowUsage> {
|
||||
let mut row_counts = [0u32; ROWS];
|
||||
|
||||
for (r, row) in heatmap.iter().enumerate() {
|
||||
if r >= ROWS { break; }
|
||||
let row_sum: u32 = row.iter().sum();
|
||||
row_counts[r] = row_sum;
|
||||
}
|
||||
|
||||
let total: u32 = row_counts.iter().sum();
|
||||
let mut result = Vec::with_capacity(ROWS);
|
||||
|
||||
for r in 0..ROWS {
|
||||
let count = row_counts[r];
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let name = ROW_NAMES[r].to_string();
|
||||
result.push(RowUsage { name, row: r, count, pct });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute top N keys by press count.
|
||||
pub fn top_keys(heatmap: &[Vec<u32>], keymap: &[Vec<u16>], n: usize) -> Vec<TopKey> {
|
||||
let mut all_keys: Vec<(u32, usize, usize)> = Vec::new(); // (count, row, col)
|
||||
|
||||
for (r, row) in heatmap.iter().enumerate() {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
if count > 0 {
|
||||
all_keys.push((count, r, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_keys.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
all_keys.truncate(n);
|
||||
|
||||
let total: u32 = heatmap.iter().flat_map(|r| r.iter()).sum();
|
||||
|
||||
let mut result = Vec::with_capacity(n);
|
||||
for (count, r, c) in all_keys {
|
||||
let code = keymap.get(r).and_then(|row| row.get(c)).copied().unwrap_or(0);
|
||||
let name = keycode::decode_keycode(code);
|
||||
let (hand, finger) = col_to_hand_finger(c);
|
||||
let hand_str = if hand == Hand::Left { "L" } else { "R" };
|
||||
let finger_str = match finger {
|
||||
Finger::Pinky => "Pinky",
|
||||
Finger::Ring => "Ring",
|
||||
Finger::Middle => "Middle",
|
||||
Finger::Index => "Index",
|
||||
Finger::Thumb => "Thumb",
|
||||
};
|
||||
let finger_label = format!("{} {}", finger_str, hand_str);
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
result.push(TopKey { name, finger: finger_label, count, pct });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Find keys that have never been pressed (count = 0, keycode != 0).
|
||||
pub fn dead_keys(heatmap: &[Vec<u32>], keymap: &[Vec<u16>]) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for (r, row) in heatmap.iter().enumerate() {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
if count > 0 { continue; }
|
||||
let code = keymap.get(r).and_then(|row| row.get(c)).copied().unwrap_or(0);
|
||||
let is_mapped = code != 0;
|
||||
if is_mapped {
|
||||
let name = keycode::decode_keycode(code);
|
||||
result.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ==================== Bigram analysis ====================
|
||||
|
||||
/// A parsed bigram entry.
|
||||
pub struct BigramEntry {
|
||||
pub from_row: u8,
|
||||
pub from_col: u8,
|
||||
pub to_row: u8,
|
||||
pub to_col: u8,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// Bigram analysis results.
|
||||
#[allow(dead_code)]
|
||||
pub struct BigramAnalysis {
|
||||
pub total: u32,
|
||||
pub alt_hand: u32,
|
||||
pub same_hand: u32,
|
||||
pub sfb: u32,
|
||||
pub alt_hand_pct: f32,
|
||||
pub same_hand_pct: f32,
|
||||
pub sfb_pct: f32,
|
||||
}
|
||||
|
||||
/// Parse bigram text lines from firmware.
|
||||
/// Format: " R2C3 -> R2C4 : 150"
|
||||
pub fn parse_bigram_lines(lines: &[String]) -> Vec<BigramEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
let has_arrow = trimmed.contains("->");
|
||||
if !has_arrow { continue; }
|
||||
|
||||
let parts: Vec<&str> = trimmed.split("->").collect();
|
||||
if parts.len() != 2 { continue; }
|
||||
|
||||
let left = parts[0].trim();
|
||||
let right_and_count = parts[1].trim();
|
||||
|
||||
let right_parts: Vec<&str> = right_and_count.split(':').collect();
|
||||
if right_parts.len() != 2 { continue; }
|
||||
|
||||
let right = right_parts[0].trim();
|
||||
let count_str = right_parts[1].trim();
|
||||
|
||||
let from = parse_rc(left);
|
||||
let to = parse_rc(right);
|
||||
let count: u32 = count_str.parse().unwrap_or(0);
|
||||
|
||||
if let (Some((fr, fc)), Some((tr, tc))) = (from, to) {
|
||||
entries.push(BigramEntry {
|
||||
from_row: fr, from_col: fc,
|
||||
to_row: tr, to_col: tc,
|
||||
count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
/// Parse "R2C3" into (row, col).
|
||||
fn parse_rc(s: &str) -> Option<(u8, u8)> {
|
||||
let s = s.trim();
|
||||
let r_pos = s.find('R')?;
|
||||
let c_pos = s.find('C')?;
|
||||
if c_pos <= r_pos { return None; }
|
||||
|
||||
let row_str = &s[r_pos + 1..c_pos];
|
||||
let col_str = &s[c_pos + 1..];
|
||||
let row: u8 = row_str.parse().ok()?;
|
||||
let col: u8 = col_str.parse().ok()?;
|
||||
Some((row, col))
|
||||
}
|
||||
|
||||
/// Analyze bigram entries for hand alternation and SFB.
|
||||
pub fn analyze_bigrams(entries: &[BigramEntry]) -> BigramAnalysis {
|
||||
let mut alt_hand: u32 = 0;
|
||||
let mut same_hand: u32 = 0;
|
||||
let mut sfb: u32 = 0;
|
||||
let mut total: u32 = 0;
|
||||
|
||||
for entry in entries {
|
||||
let (hand_from, finger_from) = col_to_hand_finger(entry.from_col as usize);
|
||||
let (hand_to, finger_to) = col_to_hand_finger(entry.to_col as usize);
|
||||
|
||||
total += entry.count;
|
||||
|
||||
if hand_from != hand_to {
|
||||
alt_hand += entry.count;
|
||||
} else {
|
||||
same_hand += entry.count;
|
||||
if finger_from == finger_to {
|
||||
sfb += entry.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let alt_hand_pct = if total > 0 { alt_hand as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let same_hand_pct = if total > 0 { same_hand as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let sfb_pct = if total > 0 { sfb as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
|
||||
BigramAnalysis {
|
||||
total, alt_hand, same_hand, sfb,
|
||||
alt_hand_pct, same_hand_pct, sfb_pct,
|
||||
}
|
||||
}
|
||||
1612
src/main.rs
Normal file
1612
src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
56
ui/components/connection_bar.slint
Normal file
56
ui/components/connection_bar.slint
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { ComboBox, Button } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { AppState, ConnectionBridge, ConnectionState } from "../globals.slint";
|
||||
|
||||
export component ConnectionBar inherits Rectangle {
|
||||
height: 48px;
|
||||
background: Theme.bg-secondary;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 8px;
|
||||
spacing: 12px;
|
||||
alignment: start;
|
||||
// Status LED (wrapped in a VerticalLayout to center it)
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background: AppState.connection == ConnectionState.connected ? Theme.connected : Theme.disconnected;
|
||||
}
|
||||
}
|
||||
|
||||
// Port selector (placeholder - ComboBox needs string model)
|
||||
Text {
|
||||
text: ConnectionBridge.selected-port != "" ? ConnectionBridge.selected-port : "No port selected";
|
||||
color: Theme.fg-primary;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// Connect/Disconnect button
|
||||
Button {
|
||||
text: AppState.connection == ConnectionState.connected ? "Disconnect" : "Connect";
|
||||
enabled: AppState.connection == ConnectionState.connected || AppState.connection == ConnectionState.disconnected;
|
||||
clicked => {
|
||||
if AppState.connection == ConnectionState.connected {
|
||||
ConnectionBridge.disconnect();
|
||||
} else {
|
||||
ConnectionBridge.connect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spacer
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
// Firmware version
|
||||
Text {
|
||||
text: AppState.firmware-version;
|
||||
color: Theme.fg-secondary;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
57
ui/components/key_button.slint
Normal file
57
ui/components/key_button.slint
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Theme } from "../theme.slint";
|
||||
import { KeycapData, KeymapBridge } from "../globals.slint";
|
||||
|
||||
export component KeyButton inherits Rectangle {
|
||||
in property <KeycapData> data;
|
||||
in property <float> scale: 1.0;
|
||||
callback clicked(int);
|
||||
|
||||
// Heat color: interpolate blue(cold) -> yellow -> red(hot)
|
||||
property <color> heat-color:
|
||||
data.heat < 0.5
|
||||
? Colors.blue.mix(Colors.yellow, data.heat * 2)
|
||||
: Colors.yellow.mix(Colors.red, (data.heat - 0.5) * 2);
|
||||
|
||||
property <color> base-color:
|
||||
KeymapBridge.heatmap-enabled && data.heat > 0 ? root.heat-color : data.color;
|
||||
|
||||
width: data.w;
|
||||
height: data.h;
|
||||
background: transparent;
|
||||
|
||||
inner := Rectangle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: data.selected ? Theme.accent-purple : root.base-color;
|
||||
border-width: 1px;
|
||||
border-color: data.selected ? Theme.accent-cyan : Theme.bg-primary;
|
||||
transform-rotation: data.rotation * 1deg;
|
||||
transform-origin: {
|
||||
x: self.width / 2,
|
||||
y: self.height / 2,
|
||||
};
|
||||
|
||||
Text {
|
||||
text: data.label;
|
||||
color: Theme.fg-primary;
|
||||
font-size: max(7px, 11px * root.scale);
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
if data.sublabel != "" : Text {
|
||||
y: parent.height - 14px * root.scale;
|
||||
text: data.sublabel;
|
||||
color: Theme.fg-secondary;
|
||||
font-size: max(5px, 8px * root.scale);
|
||||
horizontal-alignment: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
clicked => { root.clicked(data.index); }
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
271
ui/components/key_selector.slint
Normal file
271
ui/components/key_selector.slint
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { LineEdit, Button, ComboBox, ScrollView } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { KeySelectorBridge, KeymapBridge, KeyEntry, KeycapData } from "../globals.slint";
|
||||
import { KeyButton } from "key_button.slint";
|
||||
|
||||
component KeyTile inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <int> code;
|
||||
callback picked(int);
|
||||
|
||||
width: 52px;
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
background: ta.has-hover ? Theme.accent-purple : Theme.button-bg;
|
||||
|
||||
Text {
|
||||
text: root.label;
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
clicked => { root.picked(root.code); }
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
component SectionLabel inherits Text {
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
component KeySection inherits VerticalLayout {
|
||||
in property <string> title;
|
||||
in property <[KeyEntry]> keys;
|
||||
in property <int> cols: 7;
|
||||
callback picked(int);
|
||||
|
||||
property <length> tile-w: 55px;
|
||||
property <length> tile-h: 30px;
|
||||
property <length> gap: 3px;
|
||||
|
||||
spacing: 3px;
|
||||
|
||||
if keys.length > 0 : SectionLabel { text: root.title; }
|
||||
if keys.length > 0 : Rectangle {
|
||||
height: (Math.ceil(keys.length / root.cols) ) * (root.tile-h + root.gap);
|
||||
|
||||
for key[idx] in root.keys : KeyTile {
|
||||
x: mod(idx, root.cols) * (root.tile-w + root.gap);
|
||||
y: floor(idx / root.cols) * (root.tile-h + root.gap);
|
||||
width: root.tile-w;
|
||||
label: key.name;
|
||||
code: key.code;
|
||||
picked(c) => { root.picked(c); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component KeySelector inherits Rectangle {
|
||||
visible: KeymapBridge.key-selector-open;
|
||||
background: #000000aa;
|
||||
|
||||
TouchArea { clicked => { KeymapBridge.key-selector-open = false; } }
|
||||
|
||||
function pick(code: int) {
|
||||
KeySelectorBridge.select-keycode(code);
|
||||
KeymapBridge.key-selector-open = false;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(480px, parent.width - 40px);
|
||||
height: min(520px, parent.height - 40px);
|
||||
background: Theme.bg-primary;
|
||||
border-radius: 12px;
|
||||
border-width: 1px;
|
||||
border-color: Theme.accent-purple;
|
||||
clip: true;
|
||||
|
||||
TouchArea { }
|
||||
|
||||
VerticalLayout {
|
||||
padding: 14px;
|
||||
spacing: 8px;
|
||||
|
||||
// Header
|
||||
HorizontalLayout {
|
||||
|
||||
Text { text: "Select Key"; color: Theme.fg-primary; font-size: 16px; font-weight: 700; vertical-alignment: center; }
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
Rectangle {
|
||||
width: 26px; height: 26px; border-radius: 4px;
|
||||
background: close-ta.has-hover ? Theme.accent-red : Theme.button-bg;
|
||||
Text { text: "X"; color: Theme.fg-primary; font-size: 13px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
close-ta := TouchArea { clicked => { KeymapBridge.key-selector-open = false; } mouse-cursor: pointer; }
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard mode for combo key picking
|
||||
property <bool> keyboard-mode: KeymapBridge.selector-target == "combo-key1" || KeymapBridge.selector-target == "combo-key2";
|
||||
|
||||
if keyboard-mode : Text {
|
||||
text: "Click a key on the keyboard:";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
if keyboard-mode : Rectangle {
|
||||
vertical-stretch: 1;
|
||||
background: Theme.bg-surface;
|
||||
border-radius: 8px;
|
||||
clip: true;
|
||||
|
||||
property <float> kb-scale-x: self.width / KeymapBridge.content-width;
|
||||
property <float> kb-scale-y: self.height / KeymapBridge.content-height;
|
||||
property <float> kb-scale: min(kb-scale-x, kb-scale-y) * 0.95;
|
||||
property <length> kb-offset-x: (self.width - KeymapBridge.content-width * kb-scale) / 2;
|
||||
property <length> kb-offset-y: (self.height - KeymapBridge.content-height * kb-scale) / 2;
|
||||
|
||||
for keycap[idx] in KeymapBridge.keycaps : KeyButton {
|
||||
x: parent.kb-offset-x + keycap.x * parent.kb-scale;
|
||||
y: parent.kb-offset-y + keycap.y * parent.kb-scale;
|
||||
width: keycap.w * parent.kb-scale;
|
||||
height: keycap.h * parent.kb-scale;
|
||||
scale: parent.kb-scale;
|
||||
data: keycap;
|
||||
clicked(key-index) => {
|
||||
KeymapBridge.select-key(key-index);
|
||||
// The dispatch in main.rs handles combo-key1/combo-key2
|
||||
KeySelectorBridge.select-keycode(0); // trigger dispatch
|
||||
KeymapBridge.key-selector-open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal mode: search + key grid
|
||||
if !keyboard-mode : LineEdit {
|
||||
placeholder-text: "Search...";
|
||||
text <=> KeySelectorBridge.search-text;
|
||||
edited(text) => { KeySelectorBridge.apply-filter(text); }
|
||||
}
|
||||
|
||||
if !keyboard-mode : ScrollView {
|
||||
vertical-stretch: 1;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 6px;
|
||||
padding-right: 8px;
|
||||
|
||||
KeySection { title: "Letters"; keys: KeySelectorBridge.cat-letters; picked(c) => { root.pick(c); } }
|
||||
KeySection { title: "Numbers"; keys: KeySelectorBridge.cat-numbers; picked(c) => { root.pick(c); } }
|
||||
KeySection { title: "Modifiers"; keys: KeySelectorBridge.cat-modifiers; picked(c) => { root.pick(c); } }
|
||||
KeySection { title: "Navigation"; keys: KeySelectorBridge.cat-nav; picked(c) => { root.pick(c); } }
|
||||
KeySection { title: "Function Keys"; keys: KeySelectorBridge.cat-function; picked(c) => { root.pick(c); } }
|
||||
KeySection { title: "Symbols"; keys: KeySelectorBridge.cat-symbols; picked(c) => { root.pick(c); } }
|
||||
KeySection { title: "Layers"; keys: KeySelectorBridge.cat-layers; picked(c) => { root.pick(c); } }
|
||||
KeySection { title: "Special / BT / Media"; keys: KeySelectorBridge.cat-special; picked(c) => { root.pick(c); } }
|
||||
KeySection { title: "Tap Dance / Macros"; keys: KeySelectorBridge.cat-td-macro; picked(c) => { root.pick(c); } }
|
||||
|
||||
// Mod-Tap builder
|
||||
SectionLabel { text: "Mod-Tap"; }
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
height: 32px;
|
||||
Text { text: "Mod:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
mt-mod := ComboBox {
|
||||
width: 80px;
|
||||
model: ["Ctrl", "Shift", "Alt", "GUI", "RCtrl", "RShift", "RAlt", "RGUI"];
|
||||
current-index: 1;
|
||||
}
|
||||
Text { text: "Key:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
mt-key := ComboBox {
|
||||
width: 70px;
|
||||
model: ["A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z","1","2","3","4","5","6","7","8","9","0","Space","Enter","Esc","Tab","Bksp"];
|
||||
current-index: 0;
|
||||
}
|
||||
Text {
|
||||
text: "= MT " + mt-mod.current-value + " " + mt-key.current-value;
|
||||
color: Theme.accent-green;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Button {
|
||||
text: "Set";
|
||||
clicked => {
|
||||
KeySelectorBridge.apply-mt(mt-mod.current-index, mt-key.current-index);
|
||||
KeymapBridge.key-selector-open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer-Tap builder
|
||||
SectionLabel { text: "Layer-Tap"; }
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
height: 32px;
|
||||
Text { text: "Layer:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
lt-layer := ComboBox {
|
||||
width: 50px;
|
||||
model: ["0","1","2","3","4","5","6","7","8","9"];
|
||||
current-index: 1;
|
||||
}
|
||||
Text { text: "Key:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
lt-key := ComboBox {
|
||||
width: 80px;
|
||||
model: ["Space","Enter","Esc","Bksp","Tab","A","B","C","D","E"];
|
||||
current-index: 0;
|
||||
}
|
||||
Text {
|
||||
text: "= LT " + lt-layer.current-value + " " + lt-key.current-value;
|
||||
color: Theme.accent-green;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Button {
|
||||
text: "Set";
|
||||
clicked => {
|
||||
KeySelectorBridge.apply-lt(lt-layer.current-index, lt-key.current-index);
|
||||
KeymapBridge.key-selector-open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hex input
|
||||
SectionLabel { text: "Hex Code"; }
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
height: 32px;
|
||||
Text { text: "0x"; color: Theme.accent-orange; font-size: 12px; font-weight: 600; vertical-alignment: center; }
|
||||
hex-edit := LineEdit {
|
||||
width: 80px;
|
||||
text <=> KeySelectorBridge.hex-input;
|
||||
placeholder-text: "5204";
|
||||
edited(text) => { KeySelectorBridge.preview-hex(text); }
|
||||
accepted(text) => {
|
||||
KeySelectorBridge.apply-hex(text);
|
||||
KeymapBridge.key-selector-open = false;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: KeySelectorBridge.hex-preview != "" ? "= " + KeySelectorBridge.hex-preview : "";
|
||||
color: Theme.accent-green;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Button {
|
||||
text: "Set";
|
||||
clicked => {
|
||||
KeySelectorBridge.apply-hex(KeySelectorBridge.hex-input);
|
||||
KeymapBridge.key-selector-open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// None
|
||||
KeyTile {
|
||||
width: 120px;
|
||||
label: "None (transparent)";
|
||||
code: 0;
|
||||
picked(c) => { root.pick(c); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
ui/components/keyboard_view.slint
Normal file
32
ui/components/keyboard_view.slint
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Theme } from "../theme.slint";
|
||||
import { KeymapBridge, KeycapData } from "../globals.slint";
|
||||
import { KeyButton } from "key_button.slint";
|
||||
|
||||
export component KeyboardView inherits Rectangle {
|
||||
background: Theme.bg-surface;
|
||||
border-radius: 8px;
|
||||
min-height: 150px;
|
||||
clip: true;
|
||||
|
||||
// Scale to fit: min(available / content) so it always fits
|
||||
property <float> scale-x: self.width / KeymapBridge.content-width;
|
||||
property <float> scale-y: self.height / KeymapBridge.content-height;
|
||||
property <float> scale: min(root.scale-x, root.scale-y) * 0.95;
|
||||
// Center offset
|
||||
property <length> offset-x: (self.width - KeymapBridge.content-width * root.scale) / 2;
|
||||
property <length> offset-y: (self.height - KeymapBridge.content-height * root.scale) / 2;
|
||||
|
||||
for keycap[idx] in KeymapBridge.keycaps : KeyButton {
|
||||
x: root.offset-x + keycap.x * root.scale;
|
||||
y: root.offset-y + keycap.y * root.scale;
|
||||
width: keycap.w * root.scale;
|
||||
height: keycap.h * root.scale;
|
||||
scale: root.scale;
|
||||
data: keycap;
|
||||
clicked(key-index) => {
|
||||
KeymapBridge.select-key(key-index);
|
||||
KeymapBridge.selector-target = "keymap";
|
||||
KeymapBridge.key-selector-open = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
ui/components/status_bar.slint
Normal file
39
ui/components/status_bar.slint
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Theme } from "../theme.slint";
|
||||
import { AppState } from "../globals.slint";
|
||||
|
||||
export component StatusBar inherits Rectangle {
|
||||
height: 32px;
|
||||
background: Theme.bg-secondary;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: 16px;
|
||||
|
||||
// Status text
|
||||
Text {
|
||||
text: AppState.status-text;
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
// WPM
|
||||
Text {
|
||||
text: AppState.wpm > 0 ? "WPM: " + AppState.wpm : "";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// Version
|
||||
Text {
|
||||
text: "v0.6.0";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
274
ui/globals.slint
Normal file
274
ui/globals.slint
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
// Shared data structures
|
||||
export struct KeycapData {
|
||||
x: length,
|
||||
y: length,
|
||||
w: length,
|
||||
h: length,
|
||||
rotation: float,
|
||||
rotation-cx: length,
|
||||
rotation-cy: length,
|
||||
label: string,
|
||||
sublabel: string,
|
||||
keycode: int,
|
||||
color: color,
|
||||
heat: float, // 0.0 = cold, 1.0 = hottest
|
||||
selected: bool,
|
||||
index: int,
|
||||
}
|
||||
|
||||
export struct LayerInfo {
|
||||
index: int,
|
||||
name: string,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
export struct PortInfo {
|
||||
name: string,
|
||||
path: string,
|
||||
}
|
||||
|
||||
export enum ConnectionState { disconnected, connecting, connected }
|
||||
export enum ActiveTab { keymap, advanced, macros, stats, settings }
|
||||
|
||||
// Global singletons for Rust<->Slint bridge
|
||||
export global AppState {
|
||||
in-out property <ConnectionState> connection: ConnectionState.disconnected;
|
||||
in-out property <ActiveTab> active-tab: ActiveTab.keymap;
|
||||
in-out property <string> status-text: "Disconnected";
|
||||
in-out property <bool> spinner-visible: false;
|
||||
in-out property <string> firmware-version: "";
|
||||
in-out property <int> wpm: 0;
|
||||
}
|
||||
|
||||
export global ConnectionBridge {
|
||||
in property <[PortInfo]> ports;
|
||||
in-out property <string> selected-port: "";
|
||||
callback connect();
|
||||
callback disconnect();
|
||||
callback refresh-ports();
|
||||
}
|
||||
|
||||
export global KeymapBridge {
|
||||
in property <[KeycapData]> keycaps;
|
||||
in property <[LayerInfo]> layers;
|
||||
in property <length> content-width: 860px;
|
||||
in property <length> content-height: 360px;
|
||||
in-out property <int> selected-key-index: -1;
|
||||
in-out property <int> active-layer: 0;
|
||||
in-out property <string> selected-key-label: "";
|
||||
in-out property <bool> heatmap-enabled: false;
|
||||
in-out property <bool> key-selector-open: false;
|
||||
// Target for key selector: "keymap", "combo-result", "ko-trigger", "ko-result", "leader-result"
|
||||
in-out property <string> selector-target: "keymap";
|
||||
callback select-key(int);
|
||||
callback switch-layer(int);
|
||||
callback change-key(int, int); // key-index, new-keycode
|
||||
callback rename-layer(int, string); // layer-index, new-name
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
export global SettingsBridge {
|
||||
in property <[string]> available-layouts;
|
||||
in-out property <int> selected-layout-index: 0;
|
||||
callback change-layout(int);
|
||||
}
|
||||
|
||||
// ---- Stats ----
|
||||
|
||||
export struct HandBalanceData {
|
||||
left-pct: float,
|
||||
right-pct: float,
|
||||
total: int,
|
||||
}
|
||||
|
||||
export struct FingerLoadData {
|
||||
name: string,
|
||||
pct: float,
|
||||
count: int,
|
||||
}
|
||||
|
||||
export struct RowUsageData {
|
||||
name: string,
|
||||
pct: float,
|
||||
count: int,
|
||||
}
|
||||
|
||||
export struct TopKeyData {
|
||||
name: string,
|
||||
finger: string,
|
||||
count: int,
|
||||
pct: float,
|
||||
}
|
||||
|
||||
export struct BigramData {
|
||||
alt-hand-pct: float,
|
||||
same-hand-pct: float,
|
||||
sfb-pct: float,
|
||||
total: int,
|
||||
}
|
||||
|
||||
export global StatsBridge {
|
||||
in property <HandBalanceData> hand-balance;
|
||||
in property <[FingerLoadData]> finger-load;
|
||||
in property <[RowUsageData]> row-usage;
|
||||
in property <[TopKeyData]> top-keys;
|
||||
in property <[string]> dead-keys;
|
||||
in property <int> total-presses;
|
||||
in property <BigramData> bigrams;
|
||||
callback refresh-stats();
|
||||
}
|
||||
|
||||
// ---- Advanced ----
|
||||
|
||||
export struct TapDanceData {
|
||||
index: int,
|
||||
actions: [string],
|
||||
}
|
||||
|
||||
export struct ComboData {
|
||||
index: int,
|
||||
key1: string,
|
||||
key2: string,
|
||||
result: string,
|
||||
}
|
||||
|
||||
export struct LeaderData {
|
||||
index: int,
|
||||
sequence: string,
|
||||
result: string,
|
||||
}
|
||||
|
||||
export struct KeyOverrideData {
|
||||
index: int,
|
||||
trigger: string,
|
||||
result: string,
|
||||
}
|
||||
|
||||
export global AdvancedBridge {
|
||||
in property <[TapDanceData]> tap-dances;
|
||||
in property <[ComboData]> combos;
|
||||
in property <[LeaderData]> leaders;
|
||||
in property <[KeyOverrideData]> key-overrides;
|
||||
in-out property <string> bt-status: "";
|
||||
in-out property <int> tri-l1-idx: 1;
|
||||
in-out property <int> tri-l2-idx: 2;
|
||||
in-out property <int> tri-l3-idx: 3;
|
||||
// Combo creation: physical key positions
|
||||
in-out property <int> new-combo-r1: 0;
|
||||
in-out property <int> new-combo-c1: 0;
|
||||
in-out property <string> new-combo-key1-name: "Pick...";
|
||||
in-out property <int> new-combo-r2: 0;
|
||||
in-out property <int> new-combo-c2: 0;
|
||||
in-out property <string> new-combo-key2-name: "Pick...";
|
||||
in-out property <int> new-combo-result-code: 0;
|
||||
in-out property <string> new-combo-result-name: "Pick...";
|
||||
// KO creation: keycodes for trigger and result
|
||||
in-out property <int> new-ko-trigger-code: 0;
|
||||
in-out property <string> new-ko-trigger-name: "Pick...";
|
||||
in-out property <int> new-ko-trig-mod-idx: 0; // 0=None,1=Ctrl,2=Shift,...
|
||||
in-out property <int> new-ko-result-code: 0;
|
||||
in-out property <string> new-ko-result-name: "Pick...";
|
||||
in-out property <int> new-ko-res-mod-idx: 0;
|
||||
// Leader creation
|
||||
in-out property <int> new-leader-seq0-code: 0;
|
||||
in-out property <string> new-leader-seq0-name: "";
|
||||
in-out property <int> new-leader-seq1-code: 0;
|
||||
in-out property <string> new-leader-seq1-name: "";
|
||||
in-out property <int> new-leader-seq2-code: 0;
|
||||
in-out property <string> new-leader-seq2-name: "";
|
||||
in-out property <int> new-leader-seq3-code: 0;
|
||||
in-out property <string> new-leader-seq3-name: "";
|
||||
in-out property <int> new-leader-seq-count: 0;
|
||||
in-out property <int> new-leader-result-code: 0;
|
||||
in-out property <string> new-leader-result-name: "Pick...";
|
||||
in-out property <int> new-leader-mod-idx: 0;
|
||||
callback refresh-advanced();
|
||||
callback delete-combo(int);
|
||||
callback delete-leader(int);
|
||||
callback delete-ko(int);
|
||||
callback set-trilayer(int, int, int);
|
||||
callback bt-switch(int);
|
||||
callback create-combo(); // reads r1,c1,r2,c2,result from properties
|
||||
callback create-ko(int, int, int, int); // trigger_code, trig_mod_idx, result_code, res_mod_idx
|
||||
callback create-leader(int, int); // result_code, result_mod_idx (sequence comes from seq0-3 properties)
|
||||
// TAMA
|
||||
in property <string> tama-status: "";
|
||||
callback tama-action(string); // "feed", "play", "sleep", "meds", "toggle"
|
||||
// Autoshift
|
||||
in property <string> autoshift-status: "";
|
||||
callback toggle-autoshift();
|
||||
}
|
||||
|
||||
// ---- Macros ----
|
||||
|
||||
export struct MacroData {
|
||||
slot: int,
|
||||
name: string,
|
||||
steps: string,
|
||||
}
|
||||
|
||||
export struct MacroStepDisplay {
|
||||
label: string, // e.g. "A", "T 100ms"
|
||||
is-delay: bool,
|
||||
}
|
||||
|
||||
export global MacroBridge {
|
||||
in property <[MacroData]> macros;
|
||||
in-out property <int> new-slot-idx: 0;
|
||||
in-out property <string> new-name: "";
|
||||
// Visual step builder
|
||||
in property <[MacroStepDisplay]> new-steps;
|
||||
in-out property <string> new-steps-text: ""; // built text for display
|
||||
callback refresh-macros();
|
||||
callback save-macro(); // reads slot, name, steps from properties
|
||||
callback delete-macro(int);
|
||||
callback add-delay-step(int); // delay in ms (50, 100, 200, 500)
|
||||
callback remove-last-step();
|
||||
callback clear-steps();
|
||||
}
|
||||
|
||||
// ---- OTA / Flasher ----
|
||||
|
||||
export global FlasherBridge {
|
||||
in property <[string]> prog-ports;
|
||||
in-out property <string> selected-prog-port: "";
|
||||
in-out property <string> firmware-path: "";
|
||||
in-out property <int> flash-offset-index: 0; // 0=factory(0x20000), 1=ota_0(0x220000)
|
||||
in property <float> flash-progress: 0;
|
||||
in property <string> flash-status: "";
|
||||
in property <bool> flashing: false;
|
||||
callback refresh-prog-ports();
|
||||
callback browse-firmware();
|
||||
callback flash();
|
||||
}
|
||||
|
||||
// ---- Key Selector ----
|
||||
|
||||
export struct KeyEntry {
|
||||
name: string,
|
||||
code: int,
|
||||
category: string,
|
||||
}
|
||||
|
||||
export global KeySelectorBridge {
|
||||
in property <[KeyEntry]> all-keys;
|
||||
in property <[KeyEntry]> cat-letters;
|
||||
in property <[KeyEntry]> cat-numbers;
|
||||
in property <[KeyEntry]> cat-modifiers;
|
||||
in property <[KeyEntry]> cat-nav;
|
||||
in property <[KeyEntry]> cat-function;
|
||||
in property <[KeyEntry]> cat-symbols;
|
||||
in property <[KeyEntry]> cat-layers;
|
||||
in property <[KeyEntry]> cat-special;
|
||||
in property <[KeyEntry]> cat-td-macro;
|
||||
in-out property <string> search-text: "";
|
||||
in-out property <string> hex-input: "";
|
||||
in property <string> hex-preview: "";
|
||||
callback apply-filter(string);
|
||||
callback select-keycode(int);
|
||||
callback apply-hex(string);
|
||||
callback apply-mt(int, int);
|
||||
callback apply-lt(int, int);
|
||||
callback preview-hex(string); // updates hex-preview
|
||||
}
|
||||
62
ui/main.slint
Normal file
62
ui/main.slint
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { TabWidget } from "std-widgets.slint";
|
||||
import { Theme } from "theme.slint";
|
||||
import { AppState, ActiveTab } from "globals.slint";
|
||||
import { ConnectionBar } from "components/connection_bar.slint";
|
||||
import { StatusBar } from "components/status_bar.slint";
|
||||
import { KeySelector } from "components/key_selector.slint";
|
||||
import { TabKeymap } from "tabs/tab_keymap.slint";
|
||||
import { TabAdvanced } from "tabs/tab_advanced.slint";
|
||||
import { TabMacros } from "tabs/tab_macros.slint";
|
||||
import { TabStats } from "tabs/tab_stats.slint";
|
||||
import { TabSettings } from "tabs/tab_settings.slint";
|
||||
import { TabFlasher } from "tabs/tab_flasher.slint";
|
||||
|
||||
export { AppState, Theme }
|
||||
export { ConnectionBridge, KeymapBridge, SettingsBridge, StatsBridge, AdvancedBridge, MacroBridge, FlasherBridge, KeySelectorBridge } from "globals.slint";
|
||||
|
||||
export component MainWindow inherits Window {
|
||||
title: "KaSe Controller";
|
||||
preferred-width: 1000px;
|
||||
preferred-height: 700px;
|
||||
min-width: 600px;
|
||||
min-height: 450px;
|
||||
background: Theme.bg-primary;
|
||||
|
||||
VerticalLayout {
|
||||
ConnectionBar { }
|
||||
|
||||
TabWidget {
|
||||
vertical-stretch: 1;
|
||||
|
||||
Tab {
|
||||
title: "Keymap";
|
||||
TabKeymap { }
|
||||
}
|
||||
Tab {
|
||||
title: "Advanced";
|
||||
TabAdvanced { }
|
||||
}
|
||||
Tab {
|
||||
title: "Macros";
|
||||
TabMacros { }
|
||||
}
|
||||
Tab {
|
||||
title: "Stats";
|
||||
TabStats { }
|
||||
}
|
||||
Tab {
|
||||
title: "Settings";
|
||||
TabSettings { }
|
||||
}
|
||||
Tab {
|
||||
title: "Flash";
|
||||
TabFlasher { }
|
||||
}
|
||||
}
|
||||
|
||||
StatusBar { }
|
||||
}
|
||||
|
||||
// Modal overlay (above everything)
|
||||
KeySelector { }
|
||||
}
|
||||
347
ui/tabs/tab_advanced.slint
Normal file
347
ui/tabs/tab_advanced.slint
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import { Button, ComboBox, ScrollView } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { AdvancedBridge, AppState, ConnectionState, KeymapBridge } from "../globals.slint";
|
||||
|
||||
component SectionHeader inherits Text {
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
component ItemRow inherits Rectangle {
|
||||
in property <string> prefix;
|
||||
in property <string> left;
|
||||
in property <string> right;
|
||||
callback delete();
|
||||
background: Theme.bg-primary;
|
||||
border-radius: 4px;
|
||||
height: 30px;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
spacing: 6px;
|
||||
Text { text: root.prefix; color: Theme.accent-purple; font-size: 11px; vertical-alignment: center; width: 35px; }
|
||||
Text { text: root.left; color: Theme.fg-primary; font-size: 11px; vertical-alignment: center; horizontal-stretch: 1; }
|
||||
Text { text: "->"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
Text { text: root.right; color: Theme.accent-green; font-size: 11px; vertical-alignment: center; }
|
||||
Button { text: "Del"; clicked => { root.delete(); } }
|
||||
}
|
||||
}
|
||||
|
||||
component PickButton inherits Rectangle {
|
||||
in property <string> label: "Pick...";
|
||||
in property <string> target;
|
||||
width: 100px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
background: pick-ta.has-hover ? Theme.button-hover : Theme.button-bg;
|
||||
Text { text: root.label; color: Theme.fg-primary; font-size: 11px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
pick-ta := TouchArea {
|
||||
clicked => { KeymapBridge.selector-target = root.target; KeymapBridge.key-selector-open = true; }
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
component ModComboBox inherits ComboBox {
|
||||
model: ["None", "Ctrl", "Shift", "Alt", "GUI", "RCtrl", "RShift", "RAlt", "RGUI"];
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
export component TabAdvanced inherits Rectangle {
|
||||
background: Theme.bg-primary;
|
||||
|
||||
ScrollView {
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 12px;
|
||||
|
||||
// Header
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
Text { text: "Advanced Features"; color: Theme.fg-primary; font-size: 20px; font-weight: 700; vertical-alignment: center; }
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
Button {
|
||||
text: "Refresh All";
|
||||
enabled: AppState.connection == ConnectionState.connected;
|
||||
clicked => { AdvancedBridge.refresh-advanced(); }
|
||||
}
|
||||
}
|
||||
|
||||
// Two columns
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
|
||||
// ==================== Left column ====================
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
spacing: 12px;
|
||||
|
||||
// --- Tap Dance ---
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 4px;
|
||||
SectionHeader { text: "Tap Dance"; }
|
||||
if AdvancedBridge.tap-dances.length == 0 : Text { text: "No tap dances"; color: Theme.fg-secondary; font-size: 11px; }
|
||||
for td in AdvancedBridge.tap-dances : Rectangle {
|
||||
background: Theme.bg-primary;
|
||||
border-radius: 4px;
|
||||
height: 30px;
|
||||
HorizontalLayout {
|
||||
padding-left: 8px; padding-right: 8px; spacing: 8px;
|
||||
Text { text: "TD" + td.index; color: Theme.accent-purple; font-size: 11px; vertical-alignment: center; width: 35px; }
|
||||
for action in td.actions : Text { text: action; color: Theme.fg-primary; font-size: 11px; vertical-alignment: center; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Key Overrides ---
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 4px;
|
||||
SectionHeader { text: "Key Overrides"; }
|
||||
if AdvancedBridge.key-overrides.length == 0 : Text { text: "No key overrides"; color: Theme.fg-secondary; font-size: 11px; }
|
||||
for ko in AdvancedBridge.key-overrides : ItemRow {
|
||||
prefix: "KO" + ko.index;
|
||||
left: ko.trigger;
|
||||
right: ko.result;
|
||||
delete => { AdvancedBridge.delete-ko(ko.index); }
|
||||
}
|
||||
|
||||
// --- Add KO ---
|
||||
Rectangle {
|
||||
background: Theme.bg-primary;
|
||||
border-radius: 4px;
|
||||
VerticalLayout {
|
||||
padding: 10px;
|
||||
spacing: 8px;
|
||||
Text { text: "Add Key Override"; color: Theme.fg-secondary; font-size: 12px; font-weight: 600; }
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
Text { text: "Trigger:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
PickButton { label: AdvancedBridge.new-ko-trigger-name; target: "ko-trigger"; }
|
||||
Text { text: "Mod:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
ModComboBox { current-index <=> AdvancedBridge.new-ko-trig-mod-idx; }
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
Text { text: "Result:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
PickButton { label: AdvancedBridge.new-ko-result-name; target: "ko-result"; }
|
||||
Text { text: "Mod:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
ModComboBox { current-index <=> AdvancedBridge.new-ko-res-mod-idx; }
|
||||
}
|
||||
Button {
|
||||
text: "Add Key Override";
|
||||
clicked => { AdvancedBridge.create-ko(AdvancedBridge.new-ko-trigger-code, AdvancedBridge.new-ko-trig-mod-idx, AdvancedBridge.new-ko-result-code, AdvancedBridge.new-ko-res-mod-idx); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Autoshift ---
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 6px;
|
||||
SectionHeader { text: "Autoshift"; }
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
Text { text: AdvancedBridge.autoshift-status != "" ? AdvancedBridge.autoshift-status : "Unknown"; color: Theme.fg-primary; font-size: 11px; vertical-alignment: center; }
|
||||
Button { text: "Toggle"; clicked => { AdvancedBridge.toggle-autoshift(); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Right column ====================
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
spacing: 12px;
|
||||
|
||||
// --- Combos ---
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 4px;
|
||||
SectionHeader { text: "Combos"; }
|
||||
if AdvancedBridge.combos.length == 0 : Text { text: "No combos"; color: Theme.fg-secondary; font-size: 11px; }
|
||||
for combo in AdvancedBridge.combos : ItemRow {
|
||||
prefix: "#" + combo.index;
|
||||
left: combo.key1 + " + " + combo.key2;
|
||||
right: combo.result;
|
||||
delete => { AdvancedBridge.delete-combo(combo.index); }
|
||||
}
|
||||
|
||||
// --- Add Combo ---
|
||||
Rectangle {
|
||||
background: Theme.bg-primary;
|
||||
border-radius: 4px;
|
||||
VerticalLayout {
|
||||
padding: 10px;
|
||||
spacing: 8px;
|
||||
Text { text: "Add Combo"; color: Theme.fg-secondary; font-size: 12px; font-weight: 600; }
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
Text { text: "Key 1:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
PickButton { label: AdvancedBridge.new-combo-key1-name; target: "combo-key1"; }
|
||||
Text { text: "Key 2:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
PickButton { label: AdvancedBridge.new-combo-key2-name; target: "combo-key2"; }
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
Text { text: "Result:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
PickButton { label: AdvancedBridge.new-combo-result-name; target: "combo-result"; }
|
||||
Button {
|
||||
text: "Add Combo";
|
||||
clicked => { AdvancedBridge.create-combo(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Leader Keys ---
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 4px;
|
||||
SectionHeader { text: "Leader Keys"; }
|
||||
if AdvancedBridge.leaders.length == 0 : Text { text: "No leader keys"; color: Theme.fg-secondary; font-size: 11px; }
|
||||
for leader in AdvancedBridge.leaders : ItemRow {
|
||||
prefix: "#" + leader.index;
|
||||
left: leader.sequence;
|
||||
right: leader.result;
|
||||
delete => { AdvancedBridge.delete-leader(leader.index); }
|
||||
}
|
||||
|
||||
// --- Add Leader ---
|
||||
Rectangle {
|
||||
background: Theme.bg-primary;
|
||||
border-radius: 4px;
|
||||
VerticalLayout {
|
||||
padding: 10px;
|
||||
spacing: 8px;
|
||||
Text { text: "Add Leader Key"; color: Theme.fg-secondary; font-size: 12px; font-weight: 600; }
|
||||
|
||||
// Sequence: show picked keys + Add button
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
alignment: start;
|
||||
Text { text: "Sequence:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
if AdvancedBridge.new-leader-seq0-name != "" : Rectangle {
|
||||
width: 60px; height: 26px; border-radius: 3px; background: Theme.button-bg;
|
||||
Text { text: AdvancedBridge.new-leader-seq0-name; color: Theme.fg-primary; font-size: 10px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
}
|
||||
if AdvancedBridge.new-leader-seq1-name != "" : Rectangle {
|
||||
width: 60px; height: 26px; border-radius: 3px; background: Theme.button-bg;
|
||||
Text { text: AdvancedBridge.new-leader-seq1-name; color: Theme.fg-primary; font-size: 10px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
}
|
||||
if AdvancedBridge.new-leader-seq2-name != "" : Rectangle {
|
||||
width: 60px; height: 26px; border-radius: 3px; background: Theme.button-bg;
|
||||
Text { text: AdvancedBridge.new-leader-seq2-name; color: Theme.fg-primary; font-size: 10px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
}
|
||||
if AdvancedBridge.new-leader-seq3-name != "" : Rectangle {
|
||||
width: 60px; height: 26px; border-radius: 3px; background: Theme.button-bg;
|
||||
Text { text: AdvancedBridge.new-leader-seq3-name; color: Theme.fg-primary; font-size: 10px; horizontal-alignment: center; vertical-alignment: center; }
|
||||
}
|
||||
if AdvancedBridge.new-leader-seq-count < 4 : PickButton {
|
||||
width: 40px;
|
||||
label: "+";
|
||||
target: "leader-seq";
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
Text { text: "Result:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
PickButton { label: AdvancedBridge.new-leader-result-name; target: "leader-result"; }
|
||||
Text { text: "Mod:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
ModComboBox { current-index <=> AdvancedBridge.new-leader-mod-idx; }
|
||||
}
|
||||
Button {
|
||||
text: "Add Leader Key";
|
||||
clicked => { AdvancedBridge.create-leader(AdvancedBridge.new-leader-result-code, AdvancedBridge.new-leader-mod-idx); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tri-Layer ---
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 8px;
|
||||
SectionHeader { text: "Tri-Layer"; }
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
Text { text: "L1:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
|
||||
ComboBox { width: 50px; model: ["0","1","2","3","4","5","6","7","8","9"]; current-index <=> AdvancedBridge.tri-l1-idx; }
|
||||
Text { text: "L2:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
|
||||
ComboBox { width: 50px; model: ["0","1","2","3","4","5","6","7","8","9"]; current-index <=> AdvancedBridge.tri-l2-idx; }
|
||||
Text { text: "-> L3:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
|
||||
ComboBox { width: 50px; model: ["0","1","2","3","4","5","6","7","8","9"]; current-index <=> AdvancedBridge.tri-l3-idx; }
|
||||
Button { text: "Set"; clicked => { AdvancedBridge.set-trilayer(AdvancedBridge.tri-l1-idx, AdvancedBridge.tri-l2-idx, AdvancedBridge.tri-l3-idx); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- BT ---
|
||||
if AdvancedBridge.bt-status != "" : Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 4px;
|
||||
SectionHeader { text: "Bluetooth"; }
|
||||
Text { text: AdvancedBridge.bt-status; color: Theme.fg-primary; font-size: 11px; }
|
||||
}
|
||||
}
|
||||
|
||||
// --- TAMA ---
|
||||
if AdvancedBridge.tama-status != "" : Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 6px;
|
||||
SectionHeader { text: "Tamagotchi"; }
|
||||
Text { text: AdvancedBridge.tama-status; color: Theme.fg-primary; font-size: 11px; wrap: word-wrap; }
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
Button { text: "Feed"; clicked => { AdvancedBridge.tama-action("feed"); } }
|
||||
Button { text: "Play"; clicked => { AdvancedBridge.tama-action("play"); } }
|
||||
Button { text: "Sleep"; clicked => { AdvancedBridge.tama-action("sleep"); } }
|
||||
Button { text: "Meds"; clicked => { AdvancedBridge.tama-action("meds"); } }
|
||||
Button { text: "On/Off"; clicked => { AdvancedBridge.tama-action("toggle"); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
215
ui/tabs/tab_flasher.slint
Normal file
215
ui/tabs/tab_flasher.slint
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { Button, ComboBox, LineEdit } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { FlasherBridge } from "../globals.slint";
|
||||
|
||||
export component TabFlasher inherits Rectangle {
|
||||
background: Theme.bg-primary;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 20px;
|
||||
spacing: 16px;
|
||||
alignment: start;
|
||||
|
||||
Text {
|
||||
text: "Firmware Flasher";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Flash firmware via ESP32 programming port (CH340/CP2102).";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 12px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// Port selection
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 10px;
|
||||
|
||||
Text {
|
||||
text: "Programming Port (CH340/CP210x only)";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
|
||||
if FlasherBridge.prog-ports.length > 0 : ComboBox {
|
||||
horizontal-stretch: 1;
|
||||
model: FlasherBridge.prog-ports;
|
||||
selected(value) => {
|
||||
FlasherBridge.selected-prog-port = value;
|
||||
}
|
||||
}
|
||||
|
||||
if FlasherBridge.prog-ports.length == 0 : LineEdit {
|
||||
horizontal-stretch: 1;
|
||||
text <=> FlasherBridge.selected-prog-port;
|
||||
placeholder-text: "/dev/ttyUSB0";
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Refresh";
|
||||
clicked => { FlasherBridge.refresh-prog-ports(); }
|
||||
}
|
||||
}
|
||||
|
||||
if FlasherBridge.prog-ports.length == 0 : Text {
|
||||
text: "No CH340/CP210x port detected. Enter path manually or plug in the programming cable.";
|
||||
color: Theme.accent-yellow;
|
||||
font-size: 11px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flash target partition
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 10px;
|
||||
|
||||
Text {
|
||||
text: "Target Partition";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ComboBox {
|
||||
model: ["factory (0x20000)", "ota_0 (0x220000)"];
|
||||
current-index <=> FlasherBridge.flash-offset-index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware file
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 10px;
|
||||
|
||||
Text {
|
||||
text: "Firmware File";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
|
||||
Text {
|
||||
horizontal-stretch: 1;
|
||||
text: FlasherBridge.firmware-path != "" ? FlasherBridge.firmware-path : "No file selected";
|
||||
color: FlasherBridge.firmware-path != "" ? Theme.fg-primary : Theme.fg-secondary;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Browse...";
|
||||
clicked => { FlasherBridge.browse-firmware(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flash button + progress
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 10px;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
|
||||
Button {
|
||||
text: FlasherBridge.flashing ? "Flashing..." : "Flash Firmware";
|
||||
enabled: !FlasherBridge.flashing
|
||||
&& FlasherBridge.firmware-path != ""
|
||||
&& FlasherBridge.selected-prog-port != "";
|
||||
clicked => { FlasherBridge.flash(); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: FlasherBridge.flash-status;
|
||||
color: Theme.fg-primary;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
if FlasherBridge.flashing || FlasherBridge.flash-progress > 0 : Rectangle {
|
||||
height: 20px;
|
||||
background: Theme.bg-primary;
|
||||
border-radius: 4px;
|
||||
|
||||
Rectangle {
|
||||
x: 0;
|
||||
width: parent.width * clamp(FlasherBridge.flash-progress, 0, 1);
|
||||
height: 100%;
|
||||
background: FlasherBridge.flash-progress >= 1.0 ? Theme.accent-green : Theme.accent-purple;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: round(FlasherBridge.flash-progress * 100) + "%";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warning
|
||||
Rectangle {
|
||||
background: #ff555520;
|
||||
border-radius: 8px;
|
||||
border-width: 1px;
|
||||
border-color: Theme.accent-red;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 4px;
|
||||
|
||||
Text {
|
||||
text: "Warning";
|
||||
color: Theme.accent-red;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
Text {
|
||||
text: "Do not disconnect the keyboard during flashing. The device will reboot automatically when done.";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { vertical-stretch: 1; }
|
||||
}
|
||||
}
|
||||
140
ui/tabs/tab_keymap.slint
Normal file
140
ui/tabs/tab_keymap.slint
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Button, LineEdit } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { KeymapBridge, AppState, ConnectionState } from "../globals.slint";
|
||||
import { KeyboardView } from "../components/keyboard_view.slint";
|
||||
|
||||
export component TabKeymap inherits VerticalLayout {
|
||||
in-out property <bool> renaming: false;
|
||||
|
||||
padding: 8px;
|
||||
spacing: 6px;
|
||||
|
||||
// Main area: layers sidebar + keyboard
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 1;
|
||||
spacing: 6px;
|
||||
|
||||
// Layer sidebar (vertical)
|
||||
VerticalLayout {
|
||||
width: 90px;
|
||||
spacing: 4px;
|
||||
alignment: start;
|
||||
|
||||
Text {
|
||||
text: "Layers";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
for layer[idx] in KeymapBridge.layers : Rectangle {
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
background: layer.active ? Theme.accent-purple : Theme.button-bg;
|
||||
|
||||
Text {
|
||||
text: layer.name;
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
clicked => { KeymapBridge.switch-layer(layer.index); }
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// Rename
|
||||
if root.renaming : VerticalLayout {
|
||||
spacing: 4px;
|
||||
rename-input := LineEdit {
|
||||
placeholder-text: "New name";
|
||||
accepted(text) => {
|
||||
KeymapBridge.rename-layer(KeymapBridge.active-layer, text);
|
||||
root.renaming = false;
|
||||
}
|
||||
}
|
||||
Button {
|
||||
text: "Cancel";
|
||||
clicked => { root.renaming = false; }
|
||||
}
|
||||
}
|
||||
|
||||
if !root.renaming && AppState.connection == ConnectionState.connected : Rectangle {
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background: rename-ta.has-hover ? Theme.button-hover : Theme.button-bg;
|
||||
|
||||
Text {
|
||||
text: "Rename";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 10px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
rename-ta := TouchArea {
|
||||
clicked => { root.renaming = true; }
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { vertical-stretch: 1; }
|
||||
|
||||
// Heatmap toggle
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
background: KeymapBridge.heatmap-enabled ? Theme.accent-orange : Theme.button-bg;
|
||||
|
||||
Text {
|
||||
text: "Heatmap";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
clicked => { KeymapBridge.heatmap-enabled = !KeymapBridge.heatmap-enabled; }
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard view
|
||||
KeyboardView {
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Selected key info bar
|
||||
if KeymapBridge.selected-key-index >= 0 : Rectangle {
|
||||
height: 36px;
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 4px;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 8px;
|
||||
spacing: 12px;
|
||||
|
||||
Text {
|
||||
text: "Selected: " + KeymapBridge.selected-key-label;
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
Button {
|
||||
text: "Change Key...";
|
||||
clicked => {
|
||||
KeymapBridge.key-selector-open = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
ui/tabs/tab_macros.slint
Normal file
162
ui/tabs/tab_macros.slint
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { Button, ComboBox, ScrollView, LineEdit } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { MacroBridge, AppState, ConnectionState, KeymapBridge } from "../globals.slint";
|
||||
|
||||
export component TabMacros inherits Rectangle {
|
||||
background: Theme.bg-primary;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 12px;
|
||||
|
||||
// Header
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
Text { text: "Macros"; color: Theme.fg-primary; font-size: 20px; font-weight: 700; vertical-alignment: center; }
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
Button {
|
||||
text: "Refresh";
|
||||
enabled: AppState.connection == ConnectionState.connected;
|
||||
clicked => { MacroBridge.refresh-macros(); }
|
||||
}
|
||||
}
|
||||
|
||||
// New macro builder
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 10px;
|
||||
|
||||
Text { text: "Add / Edit Macro"; color: Theme.accent-cyan; font-size: 13px; font-weight: 600; }
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
|
||||
Text { text: "Slot:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
|
||||
ComboBox {
|
||||
width: 60px;
|
||||
model: ["0","1","2","3","4","5","6","7","8","9"];
|
||||
current-index <=> MacroBridge.new-slot-idx;
|
||||
}
|
||||
Text { text: "Name:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
|
||||
LineEdit {
|
||||
horizontal-stretch: 1;
|
||||
text <=> MacroBridge.new-name;
|
||||
placeholder-text: "macro name";
|
||||
}
|
||||
}
|
||||
|
||||
// Steps display
|
||||
Text { text: "Steps:"; color: Theme.fg-secondary; font-size: 12px; }
|
||||
|
||||
// Show added steps as tags
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
height: 30px;
|
||||
|
||||
for step in MacroBridge.new-steps : Rectangle {
|
||||
width: self.preferred-width + 12px;
|
||||
preferred-width: step-text.preferred-width;
|
||||
height: 26px;
|
||||
border-radius: 4px;
|
||||
background: step.is-delay ? Theme.accent-orange : Theme.accent-purple;
|
||||
|
||||
step-text := Text {
|
||||
text: step.label;
|
||||
color: Theme.fg-primary;
|
||||
font-size: 10px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
if MacroBridge.new-steps.length == 0 : Text {
|
||||
text: "(empty - add keys and delays below)";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
alignment: start;
|
||||
|
||||
Button {
|
||||
text: "Add Key";
|
||||
clicked => {
|
||||
KeymapBridge.selector-target = "macro-step";
|
||||
KeymapBridge.key-selector-open = true;
|
||||
}
|
||||
}
|
||||
|
||||
Button { text: "T 50ms"; clicked => { MacroBridge.add-delay-step(50); } }
|
||||
Button { text: "T 100ms"; clicked => { MacroBridge.add-delay-step(100); } }
|
||||
Button { text: "T 200ms"; clicked => { MacroBridge.add-delay-step(200); } }
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
Button { text: "Undo"; clicked => { MacroBridge.remove-last-step(); } }
|
||||
Button { text: "Clear"; clicked => { MacroBridge.clear-steps(); } }
|
||||
}
|
||||
|
||||
// Save
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: start;
|
||||
|
||||
Button {
|
||||
text: "Save Macro";
|
||||
enabled: AppState.connection == ConnectionState.connected;
|
||||
clicked => { MacroBridge.save-macro(); }
|
||||
}
|
||||
|
||||
if MacroBridge.new-steps-text != "" : Text {
|
||||
text: MacroBridge.new-steps-text;
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 10px;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Macro list
|
||||
ScrollView {
|
||||
vertical-stretch: 1;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 4px;
|
||||
|
||||
if MacroBridge.macros.length == 0 : Text {
|
||||
text: "No macros configured";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
for macro in MacroBridge.macros : Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 4px;
|
||||
height: 36px;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
spacing: 8px;
|
||||
|
||||
Text { text: "#" + macro.slot; color: Theme.accent-purple; font-size: 12px; vertical-alignment: center; width: 30px; }
|
||||
Text { text: macro.name; color: Theme.fg-primary; font-size: 12px; font-weight: 600; vertical-alignment: center; width: 100px; }
|
||||
Text { text: macro.steps; color: Theme.fg-secondary; font-size: 10px; vertical-alignment: center; horizontal-stretch: 1; overflow: elide; }
|
||||
Button { text: "Del"; clicked => { MacroBridge.delete-macro(macro.slot); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
ui/tabs/tab_settings.slint
Normal file
97
ui/tabs/tab_settings.slint
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { ComboBox } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { SettingsBridge } from "../globals.slint";
|
||||
|
||||
export component TabSettings inherits Rectangle {
|
||||
background: Theme.bg-primary;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 20px;
|
||||
spacing: 16px;
|
||||
alignment: start;
|
||||
|
||||
Text {
|
||||
text: "Settings";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// Keyboard layout section
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
height: 80px;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 16px;
|
||||
spacing: 12px;
|
||||
alignment: start;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: "Keyboard Layout";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 14px;
|
||||
}
|
||||
Text {
|
||||
text: "Controls how keycodes are displayed (label remapping)";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ComboBox {
|
||||
width: 200px;
|
||||
model: SettingsBridge.available-layouts;
|
||||
current-index <=> SettingsBridge.selected-layout-index;
|
||||
selected(value) => {
|
||||
SettingsBridge.change-layout(self.current-index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// About section
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 8px;
|
||||
|
||||
Text {
|
||||
text: "About";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
Text {
|
||||
text: "KaSe Controller v0.6.0";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 12px;
|
||||
}
|
||||
Text {
|
||||
text: "Split keyboard configurator — Slint UI port";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 12px;
|
||||
}
|
||||
Text {
|
||||
text: "Made with Slint";
|
||||
color: Theme.accent-purple;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spacer
|
||||
Rectangle { vertical-stretch: 1; }
|
||||
}
|
||||
}
|
||||
332
ui/tabs/tab_stats.slint
Normal file
332
ui/tabs/tab_stats.slint
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { Button, ScrollView } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { StatsBridge, AppState, ConnectionState } from "../globals.slint";
|
||||
|
||||
component BarChart inherits Rectangle {
|
||||
in property <float> value; // 0-100
|
||||
in property <string> label;
|
||||
in property <color> bar-color: Theme.accent-purple;
|
||||
height: 28px;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
|
||||
Text {
|
||||
width: 80px;
|
||||
text: root.label;
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: right;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
background: Theme.bg-primary;
|
||||
border-radius: 3px;
|
||||
|
||||
Rectangle {
|
||||
x: 0;
|
||||
width: parent.width * clamp(root.value / 100, 0, 1);
|
||||
height: 100%;
|
||||
background: root.bar-color;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: 50px;
|
||||
text: round(root.value * 10) / 10 + "%";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component TabStats inherits Rectangle {
|
||||
background: Theme.bg-primary;
|
||||
|
||||
ScrollView {
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 12px;
|
||||
|
||||
// Header
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
Text {
|
||||
text: "Typing Statistics";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
Button {
|
||||
text: "Refresh";
|
||||
enabled: AppState.connection == ConnectionState.connected;
|
||||
clicked => { StatsBridge.refresh-stats(); }
|
||||
}
|
||||
}
|
||||
|
||||
// Content in two columns
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
vertical-stretch: 1;
|
||||
|
||||
// Left column
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
spacing: 12px;
|
||||
|
||||
// Hand balance
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
vertical-stretch: 0;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 6px;
|
||||
|
||||
Text {
|
||||
text: "Hand Balance";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
// Left bar
|
||||
Rectangle {
|
||||
horizontal-stretch: StatsBridge.hand-balance.left-pct > 0 ? round(StatsBridge.hand-balance.left-pct) : 1;
|
||||
height: 24px;
|
||||
background: Theme.accent-purple;
|
||||
border-radius: 3px;
|
||||
Text {
|
||||
text: "L " + round(StatsBridge.hand-balance.left-pct) + "%";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
// Right bar
|
||||
Rectangle {
|
||||
horizontal-stretch: StatsBridge.hand-balance.right-pct > 0 ? round(StatsBridge.hand-balance.right-pct) : 1;
|
||||
height: 24px;
|
||||
background: Theme.accent-green;
|
||||
border-radius: 3px;
|
||||
Text {
|
||||
text: "R " + round(StatsBridge.hand-balance.right-pct) + "%";
|
||||
color: Theme.bg-primary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Total: " + StatsBridge.hand-balance.total + " presses";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bigrams
|
||||
if StatsBridge.bigrams.total > 0 : Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
vertical-stretch: 0;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 6px;
|
||||
|
||||
Text {
|
||||
text: "Bigram Analysis";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
BarChart {
|
||||
label: "Alt Hand";
|
||||
value: StatsBridge.bigrams.alt-hand-pct;
|
||||
bar-color: Theme.accent-green;
|
||||
}
|
||||
BarChart {
|
||||
label: "Same Hand";
|
||||
value: StatsBridge.bigrams.same-hand-pct;
|
||||
bar-color: Theme.accent-orange;
|
||||
}
|
||||
BarChart {
|
||||
label: "SFB";
|
||||
value: StatsBridge.bigrams.sfb-pct;
|
||||
bar-color: Theme.accent-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finger load
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
vertical-stretch: 1;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 4px;
|
||||
|
||||
Text {
|
||||
text: "Finger Load";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
for finger in StatsBridge.finger-load : BarChart {
|
||||
label: finger.name;
|
||||
value: finger.pct;
|
||||
bar-color: Theme.accent-orange;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Right column
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
spacing: 12px;
|
||||
|
||||
// Row usage
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
vertical-stretch: 0;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 4px;
|
||||
|
||||
Text {
|
||||
text: "Row Usage";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
for row in StatsBridge.row-usage : BarChart {
|
||||
label: row.name;
|
||||
value: row.pct;
|
||||
bar-color: Theme.accent-green;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top keys
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
vertical-stretch: 1;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 4px;
|
||||
|
||||
Text {
|
||||
text: "Top Keys";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
for key in StatsBridge.top-keys : HorizontalLayout {
|
||||
height: 22px;
|
||||
spacing: 8px;
|
||||
|
||||
Text {
|
||||
width: 80px;
|
||||
text: key.name;
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Text {
|
||||
width: 60px;
|
||||
text: key.finger;
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 10px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
background: Theme.bg-primary;
|
||||
border-radius: 3px;
|
||||
Rectangle {
|
||||
x: 0;
|
||||
width: parent.width * clamp(key.pct / 20, 0, 1);
|
||||
height: 100%;
|
||||
background: Theme.accent-yellow;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 50px;
|
||||
text: key.count;
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dead keys
|
||||
if StatsBridge.dead-keys.length > 0 : Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
vertical-stretch: 0;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 12px;
|
||||
spacing: 4px;
|
||||
|
||||
Text {
|
||||
text: "Dead Keys (never pressed)";
|
||||
color: Theme.accent-red;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
|
||||
for dk in StatsBridge.dead-keys : Rectangle {
|
||||
width: self.preferred-width + 12px;
|
||||
preferred-width: dk-text.preferred-width;
|
||||
height: 22px;
|
||||
background: Theme.bg-primary;
|
||||
border-radius: 3px;
|
||||
|
||||
dk-text := Text {
|
||||
text: dk;
|
||||
color: Theme.accent-red;
|
||||
font-size: 10px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
ui/theme.slint
Normal file
23
ui/theme.slint
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Dracula theme colors (matching the egui version)
|
||||
export global Theme {
|
||||
// Backgrounds
|
||||
out property <color> bg-primary: #282a36;
|
||||
out property <color> bg-secondary: #44475a;
|
||||
out property <color> bg-surface: #1e1e2e;
|
||||
// Text
|
||||
out property <color> fg-primary: #f8f8f2;
|
||||
out property <color> fg-secondary: #6272a4;
|
||||
// Accents
|
||||
out property <color> accent-purple: #bd93f9;
|
||||
out property <color> accent-green: #50fa7b;
|
||||
out property <color> accent-cyan: #8be9fd;
|
||||
out property <color> accent-red: #ff5555;
|
||||
out property <color> accent-yellow: #f1fa8c;
|
||||
out property <color> accent-orange: #ffb86c;
|
||||
out property <color> accent-pink: #ff79c6;
|
||||
// UI elements
|
||||
out property <color> connected: #50fa7b;
|
||||
out property <color> disconnected: #ff5555;
|
||||
out property <color> button-bg: #44475a;
|
||||
out property <color> button-hover: #6272a4;
|
||||
}
|
||||
Loading…
Reference in a new issue