feat: Binary protocol v2, config import/export, Tools tab, new layout format

- Migrate all serial commands from ASCII text to binary KS/KR frame protocol
  (SETLAYER, TD_LIST, COMBO_LIST, LEADER_LIST, KO_LIST, etc.)
- Add config import/export as JSON (keymaps, tap dances, combos, KO, leaders, macros)
- Merge Flash + Layout Preview into single Tools tab
- Replace WPF tree layout JSON format with flat groups+keys format:
  - Top-level "keys" for absolute positioning (thumbs, isolated keys)
  - "groups" with x/y/r transform, keys inside use local coordinates
  - Coordinates in units (1u = 50px), w/h default 1u, r default 0
- Layout auto-refresh (5s timer) for live preview while editing externally
- Pretty-print JSON in layout preview and export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mae PUGIN 2026-04-07 21:30:04 +02:00
parent 88b51bd399
commit d1d10b7d73
10 changed files with 1401 additions and 736 deletions

View file

@ -1,260 +1,133 @@
{
"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 } }
]
}
}
]
}
}
]
}
}
]
}
}
]
}
"name": "KaSe V2 Debug",
"rows": 5,
"cols": 13,
"keys": [
{ "row": 0, "col": 6, "x": 6.48, "y": 3.4, "r": 10 },
{ "row": 4, "col": 2, "x": 2.2, "y": 4.8, "w": 1.2 },
{ "row": 4, "col": 3, "x": 3.48, "y": 4.8, "r": 10 },
{ "row": 4, "col": 4, "x": 4.6, "y": 5.0, "r": 20 },
{ "row": 4, "col": 5, "x": 5.6, "y": 5.6, "r": 40 },
{ "row": 4, "col": 6, "x": 6.4, "y": 6.3, "r": 40 },
{ "row": 3, "col": 6, "x": 7.66, "y": 6.3, "r": -40 },
{ "row": 4, "col": 7, "x": 8.49, "y": 5.61, "r": -40 },
{ "row": 4, "col": 8, "x": 9.46, "y": 5.0, "r": -20 },
{ "row": 4, "col": 9, "x": 10.56, "y": 4.7, "r": -10 },
{ "row": 4, "col": 10, "x": 11.76, "y": 4.7, "w": 1.2 },
{ "row": 2, "col": 6, "x": 7.96, "y": 3.4, "r": -10 }
],
"groups": [
{
"x": 0, "y": 0,
"keys": [
{ "row": 4, "col": 0, "x": 0, "y": 0 },
{ "row": 0, "col": 0, "x": 0, "y": 1.08 },
{ "row": 1, "col": 0, "x": 0, "y": 2.16 },
{ "row": 2, "col": 0, "x": 0, "y": 3.24 },
{ "row": 3, "col": 0, "x": 0, "y": 4.32 }
]
},
{
"x": 1.08, "y": 0,
"keys": [
{ "row": 4, "col": 1, "x": 0, "y": 0 },
{ "row": 0, "col": 1, "x": 0, "y": 1.08 },
{ "row": 1, "col": 1, "x": 0, "y": 2.16 },
{ "row": 2, "col": 1, "x": 0, "y": 3.24 },
{ "row": 3, "col": 1, "x": 0, "y": 4.32 }
]
},
{
"x": 2.46, "y": 0.5, "r": 5,
"keys": [
{ "row": 0, "col": 2, "x": 0, "y": 0 },
{ "row": 1, "col": 2, "x": 0, "y": 1.08 },
{ "row": 2, "col": 2, "x": 0, "y": 2.16 },
{ "row": 3, "col": 2, "x": 0, "y": 3.24 }
]
},
{
"x": 3.84, "y": 0.1, "r": 10,
"keys": [
{ "row": 0, "col": 3, "x": 0, "y": 0 },
{ "row": 1, "col": 3, "x": 0, "y": 1.08 },
{ "row": 2, "col": 3, "x": 0, "y": 2.16 },
{ "row": 3, "col": 3, "x": 0, "y": 3.24 }
]
},
{
"x": 4.82, "y": 0.5, "r": 10,
"keys": [
{ "row": 0, "col": 4, "x": 0, "y": 0 },
{ "row": 1, "col": 4, "x": 0, "y": 1.08 },
{ "row": 2, "col": 4, "x": 0, "y": 2.16 },
{ "row": 3, "col": 4, "x": 0, "y": 3.24 }
]
},
{
"x": 5.8, "y": 0.76, "r": 10,
"keys": [
{ "row": 0, "col": 5, "x": 0, "y": 0 },
{ "row": 1, "col": 5, "x": 0, "y": 1.08 },
{ "row": 2, "col": 5, "x": 0, "y": 2.16 },
{ "row": 3, "col": 5, "x": 0, "y": 3.24 }
]
},
{
"x": 8.54, "y": 0.76, "r": -10,
"keys": [
{ "row": 0, "col": 7, "x": 0, "y": 0 },
{ "row": 1, "col": 7, "x": 0, "y": 1.08 },
{ "row": 2, "col": 7, "x": 0, "y": 2.16 },
{ "row": 3, "col": 7, "x": 0, "y": 3.24 }
]
},
{
"x": 9.62, "y": 0.5, "r": -10,
"keys": [
{ "row": 0, "col": 8, "x": 0, "y": 0 },
{ "row": 1, "col": 8, "x": 0, "y": 1.08 },
{ "row": 2, "col": 8, "x": 0, "y": 2.16 },
{ "row": 3, "col": 8, "x": 0, "y": 3.24 }
]
},
{
"x": 10.6, "y": 0.1, "r": -10,
"keys": [
{ "row": 0, "col": 9, "x": 0, "y": 0 },
{ "row": 1, "col": 9, "x": 0, "y": 1.08 },
{ "row": 2, "col": 9, "x": 0, "y": 2.16 },
{ "row": 3, "col": 9, "x": 0, "y": 3.24 }
]
},
{
"x": 11.98, "y": 0.4, "r": -5,
"keys": [
{ "row": 0, "col": 10, "x": 0, "y": 0 },
{ "row": 1, "col": 10, "x": 0, "y": 1.08 },
{ "row": 2, "col": 10, "x": 0, "y": 2.16 },
{ "row": 3, "col": 10, "x": 0, "y": 3.24 }
]
},
{
"x": 13.36, "y": 0,
"keys": [
{ "row": 4, "col": 11, "x": 0, "y": 0 },
{ "row": 0, "col": 11, "x": 0, "y": 1.08 },
{ "row": 1, "col": 11, "x": 0, "y": 2.16 },
{ "row": 2, "col": 11, "x": 0, "y": 3.24 },
{ "row": 3, "col": 11, "x": 0, "y": 4.32 }
]
},
{
"x": 14.44, "y": 0,
"keys": [
{ "row": 4, "col": 12, "x": 0, "y": 0 },
{ "row": 0, "col": 12, "x": 0, "y": 1.08 },
{ "row": 1, "col": 12, "x": 0, "y": 2.16 },
{ "row": 2, "col": 12, "x": 0, "y": 3.24 },
{ "row": 3, "col": 12, "x": 0, "y": 4.32 }
]
}
]
}

View file

@ -31,8 +31,10 @@ pub mod cmd {
// Statistics
pub const KEYSTATS_BIN: u8 = 0x40;
pub const KEYSTATS_TEXT: u8 = 0x41;
pub const KEYSTATS_RESET: u8 = 0x42;
pub const BIGRAMS_BIN: u8 = 0x43;
pub const BIGRAMS_TEXT: u8 = 0x44;
pub const BIGRAMS_RESET: u8 = 0x45;
// Tap Dance
@ -64,7 +66,7 @@ pub mod cmd {
pub const KO_LIST: u8 = 0x92;
pub const KO_DELETE: u8 = 0x93;
pub const WPM_QUERY: u8 = 0x94;
pub const TRILAYER_SET: u8 = 0x94;
pub const TRILAYER_SET: u8 = 0x95;
// Tamagotchi
pub const TAMA_QUERY: u8 = 0xA0;
@ -189,6 +191,32 @@ pub fn leader_set_payload(index: u8, sequence: &[u8], result: u8, result_mod: u8
payload
}
/// Build SETLAYER payload: [layer:u8][keycodes: ROWS*COLS * u16 LE]
pub fn setlayer_payload(layer: u8, keymap: &[Vec<u16>]) -> Vec<u8> {
let mut payload = Vec::with_capacity(1 + keymap.len() * keymap.first().map_or(0, |r| r.len()) * 2);
payload.push(layer);
for row in keymap {
for &kc in row {
payload.push((kc & 0xFF) as u8);
payload.push((kc >> 8) as u8);
}
}
payload
}
/// Build SET_LAYOUT_NAME payload: [layer:u8][name bytes]
pub fn set_layout_name_payload(layer: u8, name: &str) -> Vec<u8> {
let mut payload = Vec::with_capacity(1 + name.len());
payload.push(layer);
payload.extend_from_slice(name.as_bytes());
payload
}
/// Build SETKEY payload: [layer:u8][row:u8][col:u8][value:u16 LE]
pub fn setkey_payload(layer: u8, row: u8, col: u8, keycode: u16) -> Vec<u8> {
vec![layer, row, col, (keycode & 0xFF) as u8, (keycode >> 8) as u8]
}
/// Parsed KR response.
#[derive(Debug)]
pub struct KrResponse {

65
src/logic/config_io.rs Normal file
View file

@ -0,0 +1,65 @@
/// Import/export keyboard configuration as JSON.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct KeyboardConfig {
pub version: u32,
pub layer_names: Vec<String>,
/// keymaps[layer][row][col] = keycode (u16)
pub keymaps: Vec<Vec<Vec<u16>>>,
pub tap_dances: Vec<TdConfig>,
pub combos: Vec<ComboConfig>,
pub key_overrides: Vec<KoConfig>,
pub leaders: Vec<LeaderConfig>,
pub macros: Vec<MacroConfig>,
}
#[derive(Serialize, Deserialize)]
pub struct TdConfig {
pub index: u8,
pub actions: [u16; 4],
}
#[derive(Serialize, Deserialize)]
pub struct ComboConfig {
pub index: u8,
pub r1: u8,
pub c1: u8,
pub r2: u8,
pub c2: u8,
pub result: u16,
}
#[derive(Serialize, Deserialize)]
pub struct KoConfig {
pub trigger_key: u8,
pub trigger_mod: u8,
pub result_key: u8,
pub result_mod: u8,
}
#[derive(Serialize, Deserialize)]
pub struct LeaderConfig {
pub index: u8,
pub sequence: Vec<u8>,
pub result: u8,
pub result_mod: u8,
}
#[derive(Serialize, Deserialize)]
pub struct MacroConfig {
pub slot: u8,
pub name: String,
/// Steps as "kc:mod,kc:mod,..." hex string
pub steps: String,
}
impl KeyboardConfig {
pub fn to_json(&self) -> Result<String, String> {
serde_json::to_string_pretty(self).map_err(|e| e.to_string())
}
pub fn from_json(json: &str) -> Result<Self, String> {
serde_json::from_str(json).map_err(|e| e.to_string())
}
}

View file

@ -1,4 +1,4 @@
use serde_json::Value;
use serde::Deserialize;
/// A keycap with computed absolute position.
#[derive(Clone, Debug, PartialEq)]
@ -12,19 +12,89 @@ pub struct KeycapPos {
pub angle: f32, // degrees
}
const KEY_SIZE: f32 = 50.0;
const KEY_GAP: f32 = 4.0;
const UNIT_PX: f32 = 50.0; // 1u = 50 pixels
/// Parse a layout JSON string into absolute key positions.
#[derive(Deserialize)]
struct LayoutJson {
#[allow(dead_code)]
name: Option<String>,
#[allow(dead_code)]
rows: Option<usize>,
#[allow(dead_code)]
cols: Option<usize>,
#[serde(default)]
keys: Vec<KeyJson>,
#[serde(default)]
groups: Vec<GroupJson>,
}
#[derive(Deserialize)]
struct GroupJson {
#[serde(default)]
x: f32,
#[serde(default)]
y: f32,
#[serde(default)]
r: f32,
keys: Vec<KeyJson>,
}
#[derive(Deserialize)]
struct KeyJson {
row: usize,
col: usize,
#[serde(default)]
x: f32,
#[serde(default)]
y: f32,
#[serde(default = "default_one")]
w: f32,
#[serde(default = "default_one")]
h: f32,
#[serde(default)]
r: f32,
}
fn default_one() -> f32 { 1.0 }
/// Parse a layout JSON into absolute key positions.
pub fn parse_json(json: &str) -> Result<Vec<KeycapPos>, String> {
let val: Value = serde_json::from_str(json)
let layout: LayoutJson = 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() {
let mut out = Vec::new();
// Top-level keys: absolute positions
for k in &layout.keys {
out.push(KeycapPos {
row: k.row, col: k.col,
x: k.x * UNIT_PX, y: k.y * UNIT_PX,
w: k.w * UNIT_PX, h: k.h * UNIT_PX,
angle: k.r,
});
}
// Groups: apply rotation + translation to local coords
for g in &layout.groups {
let rad = g.r.to_radians();
let cos_a = rad.cos();
let sin_a = rad.sin();
for k in &g.keys {
let ax = g.x + k.x * cos_a - k.y * sin_a;
let ay = g.y + k.x * sin_a + k.y * cos_a;
out.push(KeycapPos {
row: k.row, col: k.col,
x: ax * UNIT_PX, y: ay * UNIT_PX,
w: k.w * UNIT_PX, h: k.h * UNIT_PX,
angle: g.r + k.r,
});
}
}
if out.is_empty() {
return Err("No keys found in layout".into());
}
Ok(keys)
Ok(out)
}
/// Default layout embedded at compile time.
@ -32,255 +102,3 @@ 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,
});
}

View file

@ -1,5 +1,6 @@
#[allow(dead_code)]
pub mod binary_protocol;
pub mod config_io;
#[allow(dead_code)]
pub mod flasher;
#[allow(dead_code)]

File diff suppressed because it is too large Load diff

View file

@ -81,6 +81,12 @@ export global SettingsBridge {
in property <bool> ota-flashing: false;
callback ota-browse();
callback ota-start();
// Config import/export
in property <string> config-status: "";
in property <bool> config-busy: false;
in property <float> config-progress: 0;
callback config-export();
callback config-import();
}
// ---- Stats ----
@ -266,6 +272,22 @@ export global FlasherBridge {
callback flash();
}
// ---- Layout Preview ----
export global LayoutBridge {
in property <[KeycapData]> keycaps;
in property <length> content-width: 860px;
in property <length> content-height: 360px;
in property <string> status: "";
in property <string> json-text: "";
in-out property <string> file-path: "";
in-out property <bool> auto-refresh: false;
callback load-from-file();
callback load-from-keyboard();
callback load-json(string);
callback export-json();
}
// ---- Key Selector ----
export struct KeyEntry {

View file

@ -8,10 +8,10 @@ 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";
import { TabTools } from "tabs/tab_tools.slint";
export { AppState, Theme }
export { ConnectionBridge, KeymapBridge, SettingsBridge, StatsBridge, AdvancedBridge, MacroBridge, FlasherBridge, KeySelectorBridge } from "globals.slint";
export { ConnectionBridge, KeymapBridge, SettingsBridge, StatsBridge, AdvancedBridge, MacroBridge, FlasherBridge, KeySelectorBridge, LayoutBridge } from "globals.slint";
component DarkTab inherits Rectangle {
in property <string> title;
@ -72,7 +72,7 @@ export component MainWindow inherits Window {
DarkTab { title: "Macros"; active: root.current-tab == 2; clicked => { root.current-tab = 2; AppState.tab-changed(2); } }
DarkTab { title: "Stats"; active: root.current-tab == 3; clicked => { root.current-tab = 3; AppState.tab-changed(3); } }
DarkTab { title: "Settings"; active: root.current-tab == 4; clicked => { root.current-tab = 4; AppState.tab-changed(4); } }
DarkTab { title: "Flash"; active: root.current-tab == 5; clicked => { root.current-tab = 5; AppState.tab-changed(5); } }
DarkTab { title: "Tools"; active: root.current-tab == 5; clicked => { root.current-tab = 5; AppState.tab-changed(5); } }
}
}
@ -87,7 +87,7 @@ export component MainWindow inherits Window {
if root.current-tab == 2 : TabMacros { }
if root.current-tab == 3 : TabStats { }
if root.current-tab == 4 : TabSettings { }
if root.current-tab == 5 : TabFlasher { }
if root.current-tab == 5 : TabTools { }
}
StatusBar { }

View file

@ -122,6 +122,69 @@ export component TabSettings inherits Rectangle {
}
}
// Config backup / restore
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
Text { text: "Configuration Backup"; color: Theme.accent-cyan; font-size: 14px; font-weight: 600; }
Text { text: "Export or import your full keyboard configuration (keymaps, macros, combos, etc.)"; color: Theme.fg-secondary; font-size: 11px; }
HorizontalLayout {
spacing: 12px;
DarkButton {
text: SettingsBridge.config-busy ? "Working..." : "Export Config";
primary: true;
enabled: !SettingsBridge.config-busy
&& AppState.connection == ConnectionState.connected;
clicked => { SettingsBridge.config-export(); }
}
DarkButton {
text: SettingsBridge.config-busy ? "Working..." : "Import Config";
enabled: !SettingsBridge.config-busy
&& AppState.connection == ConnectionState.connected;
clicked => { SettingsBridge.config-import(); }
}
Text {
text: SettingsBridge.config-status;
color: Theme.fg-primary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
if SettingsBridge.config-busy : Rectangle {
height: 20px;
background: Theme.bg-primary;
border-radius: 4px;
Rectangle {
x: 0;
width: parent.width * clamp(SettingsBridge.config-progress, 0, 1);
height: 100%;
background: SettingsBridge.config-progress >= 1.0 ? Theme.accent-green : Theme.accent-purple;
border-radius: 4px;
}
Text {
text: round(SettingsBridge.config-progress * 100) + "%";
color: Theme.fg-primary;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
// About
Rectangle {
background: Theme.bg-secondary;

309
ui/tabs/tab_tools.slint Normal file
View file

@ -0,0 +1,309 @@
import { DarkLineEdit } from "../components/dark_line_edit.slint";
import { Theme } from "../theme.slint";
import { DarkButton } from "../components/dark_button.slint";
import { DarkCheckbox } from "../components/dark_checkbox.slint";
import { DarkComboBox } from "../components/dark_combo_box.slint";
import { FlasherBridge, LayoutBridge, KeycapData, AppState, ConnectionState } from "../globals.slint";
import { ScrollView } from "std-widgets.slint";
export component TabTools inherits Rectangle {
background: Theme.bg-primary;
ScrollView {
VerticalLayout {
padding: 20px;
spacing: 16px;
Text {
text: "Tools";
color: Theme.fg-primary;
font-size: 20px;
font-weight: 700;
}
// ===================== LAYOUT PREVIEW =====================
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
HorizontalLayout {
spacing: 12px;
Text {
text: "Layout Preview";
color: Theme.accent-cyan;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
DarkButton {
text: "From keyboard";
primary: true;
enabled: AppState.connection == ConnectionState.connected;
clicked => { LayoutBridge.load-from-keyboard(); }
}
DarkButton {
text: "Load file...";
clicked => { LayoutBridge.load-from-file(); }
}
DarkButton {
text: "Export...";
enabled: LayoutBridge.json-text != "";
clicked => { LayoutBridge.export-json(); }
}
}
if LayoutBridge.file-path != "" : HorizontalLayout {
spacing: 12px;
DarkCheckbox {
text: "Auto-refresh (5s)";
checked <=> LayoutBridge.auto-refresh;
}
Text {
text: LayoutBridge.file-path;
color: Theme.fg-secondary;
font-size: 10px;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
}
if LayoutBridge.status != "" : Text {
text: LayoutBridge.status;
color: Theme.fg-secondary;
font-size: 11px;
}
// Keyboard render
preview-area := Rectangle {
height: 300px;
background: Theme.bg-surface;
border-radius: 8px;
clip: true;
property <float> scale-x: self.width / LayoutBridge.content-width;
property <float> scale-y: self.height / LayoutBridge.content-height;
property <float> scale: min(scale-x, scale-y) * 0.95;
property <length> offset-x: (self.width - LayoutBridge.content-width * scale) / 2;
property <length> offset-y: (self.height - LayoutBridge.content-height * scale) / 2;
if LayoutBridge.keycaps.length == 0 : Text {
text: "No layout loaded";
color: Theme.fg-secondary;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
}
for keycap[idx] in LayoutBridge.keycaps : Rectangle {
x: preview-area.offset-x + keycap.x * preview-area.scale;
y: preview-area.offset-y + keycap.y * preview-area.scale;
width: keycap.w * preview-area.scale;
height: keycap.h * preview-area.scale;
background: transparent;
Rectangle {
width: 100%;
height: 100%;
border-radius: 4px;
background: #44475a;
border-width: 1px;
border-color: Theme.bg-primary;
transform-rotation: keycap.rotation * 1deg;
transform-origin: {
x: self.width / 2,
y: self.height / 2,
};
Text {
text: keycap.label;
color: Theme.fg-primary;
font-size: max(7px, 11px * preview-area.scale);
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
// JSON preview
Rectangle {
height: 100px;
background: Theme.bg-primary;
border-radius: 4px;
clip: true;
ScrollView {
Text {
text: LayoutBridge.json-text != "" ? LayoutBridge.json-text : "// JSON will appear here";
color: LayoutBridge.json-text != "" ? Theme.fg-primary : Theme.fg-secondary;
font-size: 10px;
font-family: "monospace";
wrap: word-wrap;
}
}
}
}
}
// ===================== ESP32 FLASHER =====================
Rectangle {
background: Theme.bg-secondary;
border-radius: 8px;
VerticalLayout {
padding: 16px;
spacing: 10px;
Text {
text: "ESP32 Firmware Flasher";
color: Theme.accent-cyan;
font-size: 14px;
font-weight: 600;
}
Text {
text: "Flash via programming port (CH340/CP2102)";
color: Theme.fg-secondary;
font-size: 11px;
}
// Port
HorizontalLayout {
spacing: 8px;
if FlasherBridge.prog-ports.length > 0 : DarkComboBox {
horizontal-stretch: 1;
model: FlasherBridge.prog-ports;
selected(value) => { FlasherBridge.selected-prog-port = value; }
}
if FlasherBridge.prog-ports.length == 0 : DarkLineEdit {
horizontal-stretch: 1;
text <=> FlasherBridge.selected-prog-port;
placeholder-text: "/dev/ttyUSB0";
}
DarkButton {
text: "Refresh";
clicked => { FlasherBridge.refresh-prog-ports(); }
}
}
if FlasherBridge.prog-ports.length == 0 : Text {
text: "No CH340/CP210x port detected. Enter path manually.";
color: Theme.accent-yellow;
font-size: 11px;
wrap: word-wrap;
}
// Partition + firmware
HorizontalLayout {
spacing: 12px;
DarkComboBox {
width: 200px;
model: ["factory (0x20000)", "ota\\_0 (0x220000)"];
current-index <=> FlasherBridge.flash-offset-index;
}
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;
}
DarkButton {
text: "Browse...";
clicked => { FlasherBridge.browse-firmware(); }
}
}
// Flash button + progress
HorizontalLayout {
spacing: 12px;
DarkButton {
text: FlasherBridge.flashing ? "Flashing..." : "Flash";
primary: true;
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;
}
}
}
}
}
}