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:
Mae PUGIN 2026-04-06 20:40:34 +02:00
commit 32ee3a6d26
47 changed files with 18999 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

6511
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

19
Cargo.toml Normal file
View 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
View file

@ -0,0 +1,3 @@
fn main() {
slint_build::compile("ui/main.slint").unwrap();
}

260
default.json Normal file
View 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 } }
]
}
}
]
}
}
]
}
}
]
}
}
]
}
}

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

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

View 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::*;

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

View 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,
}
}

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

View 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
View 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
View 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(),
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

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

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

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

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

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

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