diff --git a/default.json b/default.json index b9f6ac7..55468e5 100644 --- a/default.json +++ b/default.json @@ -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 } } - - ] - } - } - ] - } - } - ] - } - } - ] - } - } - ] - } -} \ No newline at end of file + "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 } + ] + } + ] +} diff --git a/src/logic/binary_protocol.rs b/src/logic/binary_protocol.rs index fa34b85..bf2c741 100644 --- a/src/logic/binary_protocol.rs +++ b/src/logic/binary_protocol.rs @@ -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]) -> Vec { + 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 { + 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 { + vec![layer, row, col, (keycode & 0xFF) as u8, (keycode >> 8) as u8] +} + /// Parsed KR response. #[derive(Debug)] pub struct KrResponse { diff --git a/src/logic/config_io.rs b/src/logic/config_io.rs new file mode 100644 index 0000000..10c483d --- /dev/null +++ b/src/logic/config_io.rs @@ -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, + /// keymaps[layer][row][col] = keycode (u16) + pub keymaps: Vec>>, + pub tap_dances: Vec, + pub combos: Vec, + pub key_overrides: Vec, + pub leaders: Vec, + pub macros: Vec, +} + +#[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, + 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 { + serde_json::to_string_pretty(self).map_err(|e| e.to_string()) + } + + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| e.to_string()) + } +} diff --git a/src/logic/layout.rs b/src/logic/layout.rs index 3a093b1..bd5b192 100644 --- a/src/logic/layout.rs +++ b/src/logic/layout.rs @@ -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, + #[allow(dead_code)] + rows: Option, + #[allow(dead_code)] + cols: Option, + #[serde(default)] + keys: Vec, + #[serde(default)] + groups: Vec, +} + +#[derive(Deserialize)] +struct GroupJson { + #[serde(default)] + x: f32, + #[serde(default)] + y: f32, + #[serde(default)] + r: f32, + keys: Vec, +} + +#[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, 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 { 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) { - 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 = 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) { - 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) { - 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) { - 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, - }); -} diff --git a/src/logic/mod.rs b/src/logic/mod.rs index 4ce54e4..6b02cd7 100644 --- a/src/logic/mod.rs +++ b/src/logic/mod.rs @@ -1,5 +1,6 @@ #[allow(dead_code)] pub mod binary_protocol; +pub mod config_io; #[allow(dead_code)] pub mod flasher; #[allow(dead_code)] diff --git a/src/main.rs b/src/main.rs index 016dfe5..ddd3c6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,15 +17,26 @@ enum BgMsg { Keymap(Vec>), LayerNames(Vec), Disconnected, - TextLines(String, Vec), // tag, lines + #[allow(dead_code)] + TextLines(String, Vec), // kept for OTA legacy compatibility HeatmapData(Vec>, u32), // counts, max BigramLines(Vec), LayoutJson(Vec), MacroList(Vec), + TdList(Vec<[u16; 4]>), + ComboList(Vec), + LeaderList(Vec), + KoList(Vec<[u8; 4]>), + BtStatus(Vec), + TamaStatus(Vec), + AutoshiftStatus(String), + Wpm(u16), FlashProgress(f32, String), FlashDone(Result<(), String>), OtaProgress(f32, String), OtaDone(Result<(), String>), + ConfigProgress(f32, String), + ConfigDone(Result), } fn build_keycap_model(keys: &[KeycapPos]) -> Rc> { @@ -311,6 +322,263 @@ fn mod_idx_to_byte(idx: i32) -> u8 { } } +/// Export all keyboard config to JSON file via rfd save dialog. +/// Uses binary protocol v2 for all queries (fast, no text parsing). +fn export_config( + serial: &Arc>, + tx: &mpsc::Sender, +) -> Result { + use logic::binary_protocol::cmd; + use logic::config_io::*; + + let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); + + // 1. Layer names (binary LIST_LAYOUTS 0x21) + let _ = tx.send(BgMsg::ConfigProgress(0.05, "Reading layer names...".into())); + let layer_names = ser.get_layer_names().unwrap_or_default(); + let num_layers = layer_names.len().max(1); + + // 2. Keymaps — binary KEYMAP_GET per layer + let mut keymaps = Vec::new(); + for layer in 0..num_layers { + let progress = 0.05 + (layer as f32 / num_layers as f32) * 0.30; + let _ = tx.send(BgMsg::ConfigProgress(progress, format!("Reading layer {}...", layer))); + let km = ser.get_keymap(layer as u8).unwrap_or_default(); + keymaps.push(km); + } + + // 3. Tap dances — binary TD_LIST (0x51) + let _ = tx.send(BgMsg::ConfigProgress(0.40, "Reading tap dances...".into())); + let tap_dances = match ser.send_binary(cmd::TD_LIST, &[]) { + Ok(resp) => { + let td_raw = logic::parsers::parse_td_binary(&resp.payload); + td_raw.iter().enumerate() + .filter(|(_, actions)| actions.iter().any(|&a| a != 0)) + .map(|(i, actions)| TdConfig { index: i as u8, actions: *actions }) + .collect() + } + Err(_) => Vec::new(), + }; + + // 4. Combos — binary COMBO_LIST (0x61) + let _ = tx.send(BgMsg::ConfigProgress(0.50, "Reading combos...".into())); + let combos = match ser.send_binary(cmd::COMBO_LIST, &[]) { + Ok(resp) => { + logic::parsers::parse_combo_binary(&resp.payload).iter().map(|c| ComboConfig { + index: c.index, r1: c.r1, c1: c.c1, r2: c.r2, c2: c.c2, result: c.result, + }).collect() + } + Err(_) => Vec::new(), + }; + + // 5. Key overrides — binary KO_LIST (0x92) + let _ = tx.send(BgMsg::ConfigProgress(0.60, "Reading key overrides...".into())); + let key_overrides = match ser.send_binary(cmd::KO_LIST, &[]) { + Ok(resp) => { + logic::parsers::parse_ko_binary(&resp.payload).iter().map(|ko| KoConfig { + trigger_key: ko[0], trigger_mod: ko[1], result_key: ko[2], result_mod: ko[3], + }).collect() + } + Err(_) => Vec::new(), + }; + + // 6. Leaders — binary LEADER_LIST (0x71) + let _ = tx.send(BgMsg::ConfigProgress(0.70, "Reading leaders...".into())); + let leaders = match ser.send_binary(cmd::LEADER_LIST, &[]) { + Ok(resp) => { + logic::parsers::parse_leader_binary(&resp.payload).iter().map(|l| LeaderConfig { + index: l.index, sequence: l.sequence.clone(), result: l.result, result_mod: l.result_mod, + }).collect() + } + Err(_) => Vec::new(), + }; + + // 7. Macros — binary LIST_MACROS (0x30) + let _ = tx.send(BgMsg::ConfigProgress(0.80, "Reading macros...".into())); + let macros = match ser.send_binary(cmd::LIST_MACROS, &[]) { + Ok(resp) => { + logic::parsers::parse_macros_binary(&resp.payload).iter().map(|m| { + let steps_str: Vec = m.steps.iter() + .map(|s| format!("{:02X}:{:02X}", s.keycode, s.modifier)) + .collect(); + MacroConfig { slot: m.slot, name: m.name.clone(), steps: steps_str.join(",") } + }).collect() + } + Err(_) => Vec::new(), + }; + + drop(ser); // Release serial lock before file dialog + + let _ = tx.send(BgMsg::ConfigProgress(0.90, "Saving file...".into())); + + let config = KeyboardConfig { + version: 1, + layer_names, + keymaps, + tap_dances, + combos, + key_overrides, + leaders, + macros, + }; + + let json = config.to_json()?; + + let file = rfd::FileDialog::new() + .add_filter("KeSp Config", &["json"]) + .set_file_name("kesp_config.json") + .save_file(); + + match file { + Some(path) => { + std::fs::write(&path, &json).map_err(|e| format!("Write error: {}", e))?; + Ok(format!("Exported to {}", path.display())) + } + None => Ok("Export cancelled".into()), + } +} + +/// Import keyboard config using binary protocol v2. +/// SETLAYER sends a full layer in one frame (~131 bytes) instead of 65 individual SETKEY. +fn import_config( + serial: &Arc>, + tx: &mpsc::Sender, + config: &logic::config_io::KeyboardConfig, +) -> Result { + use logic::binary_protocol as bp; + + let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); + let mut errors = 0usize; + + let total_steps = (config.layer_names.len() + + config.keymaps.len() // 1 SETLAYER per layer (not per key!) + + config.tap_dances.len() + + config.combos.len() + + config.key_overrides.len() + + config.leaders.len() + + config.macros.len()) + .max(1) as f32; + let mut done = 0usize; + + // 1. Layer names — binary SET_LAYOUT_NAME (0x20) + let _ = tx.send(BgMsg::ConfigProgress(0.0, "Setting layer names...".into())); + for (i, name) in config.layer_names.iter().enumerate() { + let payload = bp::set_layout_name_payload(i as u8, name); + if ser.send_binary(bp::cmd::SET_LAYOUT_NAME, &payload).is_err() { errors += 1; } + done += 1; + } + + // 2. Keymaps — binary SETLAYER (0x10): one frame per layer! + for (layer, km) in config.keymaps.iter().enumerate() { + let progress = done as f32 / total_steps; + let _ = tx.send(BgMsg::ConfigProgress(progress, format!("Writing layer {}...", layer))); + let payload = bp::setlayer_payload(layer as u8, km); + if let Err(e) = ser.send_binary(bp::cmd::SETLAYER, &payload) { + eprintln!("SETLAYER {} failed: {}", layer, e); + errors += 1; + } + done += 1; + } + + // 3. Tap dances — binary TD_SET (0x50) + let _ = tx.send(BgMsg::ConfigProgress(done as f32 / total_steps, "Setting tap dances...".into())); + for td in &config.tap_dances { + let payload = bp::td_set_payload(td.index, &td.actions); + if ser.send_binary(bp::cmd::TD_SET, &payload).is_err() { errors += 1; } + done += 1; + } + + // 4. Combos — binary COMBO_SET (0x60) + let _ = tx.send(BgMsg::ConfigProgress(done as f32 / total_steps, "Setting combos...".into())); + for combo in &config.combos { + let payload = bp::combo_set_payload(combo.index, combo.r1, combo.c1, combo.r2, combo.c2, combo.result as u8); + if ser.send_binary(bp::cmd::COMBO_SET, &payload).is_err() { errors += 1; } + done += 1; + } + + // 5. Key overrides — binary KO_SET (0x91) + let _ = tx.send(BgMsg::ConfigProgress(done as f32 / total_steps, "Setting key overrides...".into())); + for (i, ko) in config.key_overrides.iter().enumerate() { + let payload = bp::ko_set_payload(i as u8, ko.trigger_key, ko.trigger_mod, ko.result_key, ko.result_mod); + if ser.send_binary(bp::cmd::KO_SET, &payload).is_err() { errors += 1; } + done += 1; + } + + // 6. Leaders — binary LEADER_SET (0x70) + let _ = tx.send(BgMsg::ConfigProgress(done as f32 / total_steps, "Setting leaders...".into())); + for leader in &config.leaders { + let payload = bp::leader_set_payload(leader.index, &leader.sequence, leader.result, leader.result_mod); + if ser.send_binary(bp::cmd::LEADER_SET, &payload).is_err() { errors += 1; } + done += 1; + } + + // 7. Macros — binary MACRO_ADD_SEQ (0x32) + let _ = tx.send(BgMsg::ConfigProgress(done as f32 / total_steps, "Setting macros...".into())); + for m in &config.macros { + let payload = bp::macro_add_seq_payload(m.slot, &m.name, &m.steps); + if ser.send_binary(bp::cmd::MACRO_ADD_SEQ, &payload).is_err() { errors += 1; } + } + + // 8. Refresh UI + let _ = tx.send(BgMsg::ConfigProgress(0.95, "Refreshing...".into())); + let names = ser.get_layer_names().unwrap_or_default(); + let km = ser.get_keymap(0).unwrap_or_default(); + let _ = tx.send(BgMsg::LayerNames(names)); + let _ = tx.send(BgMsg::Keymap(km)); + + let total_keys: usize = config.keymaps.iter() + .map(|l| l.iter().map(|r| r.len()).sum::()) + .sum(); + + if errors > 0 { + Ok(format!("Import done with {} errors (check stderr)", errors)) + } else { + Ok(format!("Imported: {} layers, {} keys, {} TD, {} combos, {} KO, {} leaders, {} macros", + config.layer_names.len(), + total_keys, + config.tap_dances.len(), + config.combos.len(), + config.key_overrides.len(), + config.leaders.len(), + config.macros.len(), + )) + } +} + +/// Populate LayoutBridge with parsed JSON layout for preview. +fn populate_layout_preview(window: &MainWindow, json: &str) { + let lb = window.global::(); + match logic::layout::parse_json(json) { + Ok(keys) => { + let keycaps: Vec = keys.iter().enumerate().map(|(idx, kp)| KeycapData { + x: kp.x, y: kp.y, w: kp.w, h: kp.h, + rotation: kp.angle, + rotation_cx: kp.w / 2.0, rotation_cy: kp.h / 2.0, + label: SharedString::from(format!("R{}C{}", kp.row, kp.col)), + sublabel: SharedString::default(), + keycode: 0, color: slint::Color::from_argb_u8(255, 0x44, 0x47, 0x5a), + heat: 0.0, selected: false, index: idx as i32, + }).collect(); + // Compute content bounds + let max_x = keys.iter().map(|k| k.x + k.w).fold(0.0f32, f32::max); + let max_y = keys.iter().map(|k| k.y + k.h).fold(0.0f32, f32::max); + lb.set_content_width(max_x + 20.0); + lb.set_content_height(max_y + 20.0); + lb.set_keycaps(ModelRc::from(Rc::new(VecModel::from(keycaps)))); + lb.set_status(SharedString::from(format!("{} keys loaded", keys.len()))); + // Pretty-print JSON for display and export + let pretty_json = serde_json::from_str::(json) + .and_then(|v| serde_json::to_string_pretty(&v)) + .unwrap_or_else(|_| json.to_string()); + lb.set_json_text(SharedString::from(pretty_json)); + } + Err(e) => { + lb.set_status(SharedString::from(format!("Parse error: {}", e))); + lb.set_json_text(SharedString::from(json)); + } + } +} + fn main() { let keys = logic::layout::default_layout(); let keys_arc: Rc>> = Rc::new(std::cell::RefCell::new(keys.clone())); @@ -475,13 +743,12 @@ fn main() { let window_weak = window.as_weak(); keymap_bridge.on_rename_layer(move |layer_idx, new_name| { - let cmd = logic::protocol::cmd_set_layer_name(layer_idx as u8, &new_name); + let payload = logic::binary_protocol::set_layout_name_payload(layer_idx as u8, &new_name); let serial = serial.clone(); let tx = tx.clone(); std::thread::spawn(move || { let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); - std::thread::sleep(std::time::Duration::from_millis(100)); + let _ = ser.send_binary(logic::binary_protocol::cmd::SET_LAYOUT_NAME, &payload); if let Ok(names) = ser.get_layer_names() { let _ = tx.send(BgMsg::LayerNames(names)); } @@ -504,9 +771,10 @@ fn main() { let tx = tx.clone(); std::thread::spawn(move || { let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let lines = ser.query_command("KEYSTATS?").unwrap_or_default(); - let (data, max) = logic::parsers::parse_heatmap_lines(&lines); - let _ = tx.send(BgMsg::HeatmapData(data, max)); + if let Ok(resp) = ser.send_binary(logic::binary_protocol::cmd::KEYSTATS_BIN, &[]) { + let (data, max) = logic::parsers::parse_keystats_binary(&resp.payload); + let _ = tx.send(BgMsg::HeatmapData(data, max)); + } }); }); } @@ -573,13 +841,24 @@ fn main() { let tx = tx.clone(); match tab_idx { 1 => { - // Advanced: refresh TD, combo, leader, KO, BT + // Advanced: refresh TD, combo, leader, KO, BT via binary std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - for (tag, cmd) in [("td", "TD?"), ("combo", "COMBO?"), ("leader", "LEADER?"), ("ko", "KO?"), ("bt", "BT?")] { - std::thread::sleep(std::time::Duration::from_millis(50)); - let lines = ser.query_command(cmd).unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines(tag.into(), lines)); + if let Ok(r) = ser.send_binary(cmd::TD_LIST, &[]) { + let _ = tx.send(BgMsg::TdList(logic::parsers::parse_td_binary(&r.payload))); + } + if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) { + let _ = tx.send(BgMsg::ComboList(logic::parsers::parse_combo_binary(&r.payload))); + } + if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) { + let _ = tx.send(BgMsg::LeaderList(logic::parsers::parse_leader_binary(&r.payload))); + } + if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) { + let _ = tx.send(BgMsg::KoList(logic::parsers::parse_ko_binary(&r.payload))); + } + if let Ok(r) = ser.send_binary(cmd::BT_QUERY, &[]) { + let _ = tx.send(BgMsg::BtStatus(logic::parsers::parse_bt_binary(&r.payload))); } }); } @@ -594,14 +873,18 @@ fn main() { }); } 3 => { - // Stats: refresh heatmap + bigrams + // Stats: refresh heatmap + bigrams via binary std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let lines = ser.query_command("KEYSTATS?").unwrap_or_default(); - let (data, max) = logic::parsers::parse_heatmap_lines(&lines); - let _ = tx.send(BgMsg::HeatmapData(data, max)); - std::thread::sleep(std::time::Duration::from_millis(50)); - let bigram_lines = ser.query_command("BIGRAMS?").unwrap_or_default(); + if let Ok(r) = ser.send_binary(cmd::KEYSTATS_BIN, &[]) { + let (data, max) = logic::parsers::parse_keystats_binary(&r.payload); + let _ = tx.send(BgMsg::HeatmapData(data, max)); + } + // Bigrams: keep text query (binary format needs dedicated parser) + let bigram_lines = if let Ok(r) = ser.send_binary(logic::binary_protocol::cmd::BIGRAMS_TEXT, &[]) { + String::from_utf8_lossy(&r.payload).lines().map(|l| l.to_string()).collect() + } else { Vec::new() }; let _ = tx.send(BgMsg::BigramLines(bigram_lines)); }); } @@ -697,11 +980,11 @@ fn main() { let keycaps = w.global::().get_keycaps(); update_keycap_labels(&keycaps, &keys, &km, &layout); - let cmd = logic::protocol::cmd_set_key(layer, row as u8, col as u8, code); + let payload = logic::binary_protocol::setkey_payload(layer, row as u8, col as u8, code); let serial = serial.clone(); std::thread::spawn(move || { let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); + let _ = ser.send_binary(logic::binary_protocol::cmd::SETKEY, &payload); }); w.global::().set_status_text( @@ -946,13 +1229,15 @@ fn main() { let serial = serial.clone(); let tx = tx.clone(); std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let lines = ser.query_command("KEYSTATS?").unwrap_or_default(); - let (data, max) = logic::parsers::parse_heatmap_lines(&lines); - let _ = tx.send(BgMsg::HeatmapData(data, max)); - // Also fetch bigrams (delay to avoid serial confusion) - std::thread::sleep(std::time::Duration::from_millis(50)); - let bigram_lines = ser.query_command("BIGRAMS?").unwrap_or_default(); + if let Ok(r) = ser.send_binary(cmd::KEYSTATS_BIN, &[]) { + let (data, max) = logic::parsers::parse_keystats_binary(&r.payload); + let _ = tx.send(BgMsg::HeatmapData(data, max)); + } + let bigram_lines = if let Ok(r) = ser.send_binary(logic::binary_protocol::cmd::BIGRAMS_TEXT, &[]) { + String::from_utf8_lossy(&r.payload).lines().map(|l| l.to_string()).collect() + } else { Vec::new() }; let _ = tx.send(BgMsg::BigramLines(bigram_lines)); }); }); @@ -967,12 +1252,22 @@ fn main() { let serial = serial.clone(); let tx = tx.clone(); std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let queries = [("td", "TD?"), ("combo", "COMBO?"), ("leader", "LEADER?"), ("ko", "KO?"), ("bt", "BT?")]; - for (tag, cmd) in queries { - std::thread::sleep(std::time::Duration::from_millis(50)); - let lines = ser.query_command(cmd).unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines((*tag).into(), lines)); + if let Ok(r) = ser.send_binary(cmd::TD_LIST, &[]) { + let _ = tx.send(BgMsg::TdList(logic::parsers::parse_td_binary(&r.payload))); + } + if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) { + let _ = tx.send(BgMsg::ComboList(logic::parsers::parse_combo_binary(&r.payload))); + } + if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) { + let _ = tx.send(BgMsg::LeaderList(logic::parsers::parse_leader_binary(&r.payload))); + } + if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) { + let _ = tx.send(BgMsg::KoList(logic::parsers::parse_ko_binary(&r.payload))); + } + if let Ok(r) = ser.send_binary(cmd::BT_QUERY, &[]) { + let _ = tx.send(BgMsg::BtStatus(logic::parsers::parse_bt_binary(&r.payload))); } }); }); @@ -986,13 +1281,13 @@ fn main() { window.global::().on_delete_combo(move |idx| { let serial = serial.clone(); let tx = tx.clone(); - let cmd = logic::protocol::cmd_combodel(idx as u8); std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); - std::thread::sleep(std::time::Duration::from_millis(50)); - let lines = ser.query_command("COMBO?").unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines("combo".into(), lines)); + let _ = ser.send_binary(cmd::COMBO_DELETE, &[idx as u8]); + if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) { + let _ = tx.send(BgMsg::ComboList(logic::parsers::parse_combo_binary(&r.payload))); + } }); }); } @@ -1005,13 +1300,13 @@ fn main() { window.global::().on_delete_leader(move |idx| { let serial = serial.clone(); let tx = tx.clone(); - let cmd = logic::protocol::cmd_leaderdel(idx as u8); std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); - std::thread::sleep(std::time::Duration::from_millis(50)); - let lines = ser.query_command("LEADER?").unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines("leader".into(), lines)); + let _ = ser.send_binary(cmd::LEADER_DELETE, &[idx as u8]); + if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) { + let _ = tx.send(BgMsg::LeaderList(logic::parsers::parse_leader_binary(&r.payload))); + } }); }); } @@ -1024,13 +1319,13 @@ fn main() { window.global::().on_delete_ko(move |idx| { let serial = serial.clone(); let tx = tx.clone(); - let cmd = logic::protocol::cmd_kodel(idx as u8); std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); - std::thread::sleep(std::time::Duration::from_millis(50)); - let lines = ser.query_command("KO?").unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines("ko".into(), lines)); + let _ = ser.send_binary(cmd::KO_DELETE, &[idx as u8]); + if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) { + let _ = tx.send(BgMsg::KoList(logic::parsers::parse_ko_binary(&r.payload))); + } }); }); } @@ -1041,14 +1336,11 @@ fn main() { let window_weak = window.as_weak(); window.global::().on_set_trilayer(move |l1, l2, l3| { - let l1 = l1 as u8; - let l2 = l2 as u8; - let l3 = l3 as u8; - let cmd = logic::protocol::cmd_trilayer(l1, l2, l3); + let payload = vec![l1 as u8, l2 as u8, l3 as u8]; let serial = serial.clone(); std::thread::spawn(move || { let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); + let _ = ser.send_binary(logic::binary_protocol::cmd::TRILAYER_SET, &payload); }); if let Some(w) = window_weak.upgrade() { w.global::().set_status_text( @@ -1063,11 +1355,10 @@ fn main() { let serial = serial.clone(); window.global::().on_bt_switch(move |slot| { - let cmd = logic::protocol::cmd_bt_switch(slot as u8); let serial = serial.clone(); std::thread::spawn(move || { let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); + let _ = ser.send_binary(logic::binary_protocol::cmd::BT_SWITCH, &[slot as u8]); }); }); } @@ -1078,22 +1369,23 @@ fn main() { let tx = bg_tx.clone(); window.global::().on_tama_action(move |action| { - let cmd = match action.as_str() { - "feed" => "TAMA FEED", - "play" => "TAMA PLAY", - "sleep" => "TAMA SLEEP", - "meds" => "TAMA MEDS", - "toggle" => "TAMA TOGGLE", + use logic::binary_protocol::cmd; + let action_cmd = match action.as_str() { + "feed" => cmd::TAMA_FEED, + "play" => cmd::TAMA_PLAY, + "sleep" => cmd::TAMA_SLEEP, + "meds" => cmd::TAMA_MEDICINE, + "toggle" => cmd::TAMA_ENABLE, // toggle handled by firmware _ => return, }; let serial = serial.clone(); let tx = tx.clone(); - let cmd = cmd.to_string(); std::thread::spawn(move || { let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); - let lines = ser.query_command("TAMA?").unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines("tama".into(), lines)); + let _ = ser.send_binary(action_cmd, &[]); + if let Ok(r) = ser.send_binary(cmd::TAMA_QUERY, &[]) { + let _ = tx.send(BgMsg::TamaStatus(logic::parsers::parse_tama_binary(&r.payload))); + } }); }); } @@ -1108,9 +1400,16 @@ fn main() { let tx = tx.clone(); std::thread::spawn(move || { let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command("AUTOSHIFT TOGGLE"); - let lines = ser.query_command("AUTOSHIFT?").unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines("autoshift".into(), lines)); + match ser.send_binary(logic::binary_protocol::cmd::AUTOSHIFT_TOGGLE, &[]) { + Ok(r) => { + let enabled = r.payload.first().copied().unwrap_or(0); + let status = if enabled != 0 { "Autoshift: ON" } else { "Autoshift: OFF" }; + let _ = tx.send(BgMsg::AutoshiftStatus(status.to_string())); + } + Err(e) => { + let _ = tx.send(BgMsg::AutoshiftStatus(format!("Error: {}", e))); + } + } }); }); } @@ -1136,15 +1435,16 @@ fn main() { return; } let next_idx = adv.get_combos().row_count() as u8; - let cmd = logic::protocol::cmd_comboset(next_idx, r1, c1, r2, c2, result); + let payload = logic::binary_protocol::combo_set_payload(next_idx, r1, c1, r2, c2, result); let serial = serial.clone(); let tx = tx.clone(); std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); - std::thread::sleep(std::time::Duration::from_millis(50)); - let lines = ser.query_command("COMBO?").unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines("combo".into(), lines)); + let _ = ser.send_binary(cmd::COMBO_SET, &payload); + if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) { + let _ = tx.send(BgMsg::ComboList(logic::parsers::parse_combo_binary(&r.payload))); + } }); w.global::().set_status_text("Creating combo...".into()); }); @@ -1168,15 +1468,16 @@ fn main() { | ((adv.get_new_ko_res_shift() as u8) << 1) | ((adv.get_new_ko_res_alt() as u8) << 2); let next_idx = adv.get_key_overrides().row_count() as u8; - let cmd = logic::protocol::cmd_koset(next_idx, trig, trig_mod, result, res_mod); + let payload = logic::binary_protocol::ko_set_payload(next_idx, trig, trig_mod, result, res_mod); let serial = serial.clone(); let tx = tx.clone(); std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); - std::thread::sleep(std::time::Duration::from_millis(50)); - let lines = ser.query_command("KO?").unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines("ko".into(), lines)); + let _ = ser.send_binary(cmd::KO_SET, &payload); + if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) { + let _ = tx.send(BgMsg::KoList(logic::parsers::parse_ko_binary(&r.payload))); + } }); w.global::().set_status_text("Creating key override...".into()); }); @@ -1200,15 +1501,16 @@ fn main() { let result = result_code as u8; let result_mod = mod_idx_to_byte(mod_idx); let next_idx = adv.get_leaders().row_count() as u8; - let cmd = logic::protocol::cmd_leaderset(next_idx, &sequence, result, result_mod); + let payload = logic::binary_protocol::leader_set_payload(next_idx, &sequence, result, result_mod); let serial = serial.clone(); let tx = tx.clone(); std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); - std::thread::sleep(std::time::Duration::from_millis(50)); - let lines = ser.query_command("LEADER?").unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines("leader".into(), lines)); + let _ = ser.send_binary(cmd::LEADER_SET, &payload); + if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) { + let _ = tx.send(BgMsg::LeaderList(logic::parsers::parse_leader_binary(&r.payload))); + } }); if let Some(w) = window_weak.upgrade() { w.global::().set_status_text("Creating leader key...".into()); @@ -1232,9 +1534,8 @@ fn main() { let _ = tx.send(BgMsg::MacroList(macros)); } } else { - let lines = ser.query_command("MACROS?").unwrap_or_default(); - let macros = logic::parsers::parse_macro_lines(&lines); - let _ = tx.send(BgMsg::MacroList(macros)); + // Legacy fallback — should not happen with v2 firmware + let _ = ser.send_binary(logic::binary_protocol::cmd::LIST_MACROS, &[]); } }); }); @@ -1293,15 +1594,15 @@ fn main() { }).collect(); let steps_text = steps_str.join(","); drop(steps); - let cmd = logic::protocol::cmd_macroseq(slot_num, &name, &steps_text); + let payload = logic::binary_protocol::macro_add_seq_payload(slot_num, &name, &steps_text); let serial = serial.clone(); let tx = tx.clone(); std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); - std::thread::sleep(std::time::Duration::from_millis(100)); - if let Ok(resp) = ser.send_binary(logic::binary_protocol::cmd::LIST_MACROS, &[]) { + let _ = ser.send_binary(cmd::MACRO_ADD_SEQ, &payload); + if let Ok(resp) = ser.send_binary(cmd::LIST_MACROS, &[]) { let macros = logic::parsers::parse_macros_binary(&resp.payload); let _ = tx.send(BgMsg::MacroList(macros)); } @@ -1318,14 +1619,16 @@ fn main() { let tx = bg_tx.clone(); window.global::().on_delete_macro(move |slot| { - let cmd = logic::protocol::cmd_macro_del(slot as u8); let serial = serial.clone(); let tx = tx.clone(); std::thread::spawn(move || { + use logic::binary_protocol::cmd; let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); - let _ = ser.send_command(&cmd); - let lines = ser.query_command("MACROS?").unwrap_or_default(); - let _ = tx.send(BgMsg::TextLines("macros".into(), lines)); + let _ = ser.send_binary(cmd::MACRO_DELETE, &logic::binary_protocol::macro_delete_payload(slot as u8)); + if let Ok(resp) = ser.send_binary(cmd::LIST_MACROS, &[]) { + let macros = logic::parsers::parse_macros_binary(&resp.payload); + let _ = tx.send(BgMsg::MacroList(macros)); + } }); }); } @@ -1478,6 +1781,76 @@ fn main() { }); } + // --- Config Export --- + { + let serial = serial.clone(); + let tx = bg_tx.clone(); + let window_weak = window.as_weak(); + + window.global::().on_config_export(move || { + let Some(w) = window_weak.upgrade() else { return }; + w.global::().set_config_busy(true); + w.global::().set_config_progress(0.0); + w.global::().set_config_status(SharedString::from("Reading config...")); + + let serial = serial.clone(); + let tx = tx.clone(); + std::thread::spawn(move || { + let result = export_config(&serial, &tx); + let _ = tx.send(BgMsg::ConfigDone(result)); + }); + }); + } + + // --- Config Import --- + { + let serial = serial.clone(); + let tx = bg_tx.clone(); + let window_weak = window.as_weak(); + + window.global::().on_config_import(move || { + let serial = serial.clone(); + let tx = tx.clone(); + let window_weak = window_weak.clone(); + std::thread::spawn(move || { + let file = rfd::FileDialog::new() + .add_filter("KeSp Config", &["json"]) + .pick_file(); + let Some(path) = file else { return }; + + let _ = slint::invoke_from_event_loop({ + let window_weak = window_weak.clone(); + move || { + if let Some(w) = window_weak.upgrade() { + let s = w.global::(); + s.set_config_busy(true); + s.set_config_progress(0.0); + s.set_config_status(SharedString::from("Importing config...")); + } + } + }); + + let json = match std::fs::read_to_string(&path) { + Ok(j) => j, + Err(e) => { + let _ = tx.send(BgMsg::ConfigDone(Err(format!("Read error: {}", e)))); + return; + } + }; + let config = match logic::config_io::KeyboardConfig::from_json(&json) { + Ok(c) => c, + Err(e) => { + let _ = tx.send(BgMsg::ConfigDone(Err(format!("Parse error: {}", e)))); + return; + } + }; + + let result = import_config(&serial, &tx, &config); + let _ = tx.send(BgMsg::ConfigDone(result)); + }); + }); + } + // --- Flasher: refresh prog ports --- { let window_weak = window.as_weak(); @@ -1582,6 +1955,109 @@ fn main() { ); } + // --- Layout preview: load from file --- + { + let window_weak = window.as_weak(); + window.global::().on_load_from_file(move || { + let window_weak = window_weak.clone(); + std::thread::spawn(move || { + let file = rfd::FileDialog::new() + .add_filter("Layout JSON", &["json"]) + .pick_file(); + let Some(path) = file else { return }; + let json = match std::fs::read_to_string(&path) { + Ok(j) => j, + Err(e) => { + let err = format!("Read error: {}", e); + let _ = slint::invoke_from_event_loop({ + let window_weak = window_weak.clone(); + move || { + if let Some(w) = window_weak.upgrade() { + w.global::().set_status(SharedString::from(err)); + } + } + }); + return; + } + }; + let path_str = path.to_string_lossy().to_string(); + let _ = slint::invoke_from_event_loop({ + let window_weak = window_weak.clone(); + move || { + if let Some(w) = window_weak.upgrade() { + w.global::().set_file_path(SharedString::from(&path_str)); + populate_layout_preview(&w, &json); + } + } + }); + }); + }); + } + + // --- Layout preview: load from keyboard --- + { + let serial = serial.clone(); + let window_weak = window.as_weak(); + window.global::().on_load_from_keyboard(move || { + let serial = serial.clone(); + let window_weak = window_weak.clone(); + std::thread::spawn(move || { + let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner()); + let json = match ser.get_layout_json() { + Ok(j) => j, + Err(e) => { + let err = format!("Error: {}", e); + let _ = slint::invoke_from_event_loop({ + let window_weak = window_weak.clone(); + move || { + if let Some(w) = window_weak.upgrade() { + w.global::().set_status(SharedString::from(err)); + } + } + }); + return; + } + }; + let _ = slint::invoke_from_event_loop({ + let window_weak = window_weak.clone(); + move || { + if let Some(w) = window_weak.upgrade() { + populate_layout_preview(&w, &json); + } + } + }); + }); + }); + } + + // --- Layout preview: export JSON --- + { + let window_weak = window.as_weak(); + window.global::().on_export_json(move || { + let Some(w) = window_weak.upgrade() else { return }; + let json = w.global::().get_json_text().to_string(); + if json.is_empty() { return; } + let window_weak = window_weak.clone(); + std::thread::spawn(move || { + let file = rfd::FileDialog::new() + .add_filter("Layout JSON", &["json"]) + .set_file_name("layout.json") + .save_file(); + if let Some(path) = file { + let msg = match std::fs::write(&path, &json) { + Ok(()) => format!("Exported to {}", path.display()), + Err(e) => format!("Write error: {}", e), + }; + let _ = slint::invoke_from_event_loop(move || { + if let Some(w) = window_weak.upgrade() { + w.global::().set_status(SharedString::from(msg)); + } + }); + } + }); + }); + } + // --- Poll background messages via timer --- { let window_weak = window.as_weak(); @@ -1766,124 +2242,91 @@ fn main() { SharedString::from(format!("Stats loaded ({} total presses, max {})", balance.total, max)) ); } - BgMsg::TextLines(tag, lines) => { - match tag.as_str() { - "td" => { - let td_data = logic::parsers::parse_td_lines(&lines); - let model: Vec = td_data.iter().enumerate() - .filter(|(_, actions)| actions.iter().any(|&a| a != 0)) - .map(|(i, actions)| TapDanceData { - index: i as i32, - actions: ModelRc::from(Rc::new(VecModel::from( - actions.iter().map(|&a| TapDanceAction { - name: SharedString::from(keycode::decode_keycode(a)), - code: a as i32, - }).collect::>() - ))), - }) - .collect(); - window.global::().set_tap_dances( - ModelRc::from(Rc::new(VecModel::from(model))) - ); + BgMsg::TextLines(_tag, _lines) => { + // Legacy text handler — kept for OTA compatibility only + } + BgMsg::TdList(td_data) => { + let model: Vec = td_data.iter().enumerate() + .filter(|(_, actions)| actions.iter().any(|&a| a != 0)) + .map(|(i, actions)| TapDanceData { + index: i as i32, + actions: ModelRc::from(Rc::new(VecModel::from( + actions.iter().map(|&a| TapDanceAction { + name: SharedString::from(keycode::decode_keycode(a)), + code: a as i32, + }).collect::>() + ))), + }) + .collect(); + window.global::().set_tap_dances( + ModelRc::from(Rc::new(VecModel::from(model))) + ); + } + BgMsg::ComboList(combo_data) => { + let model: Vec = combo_data.iter().map(|c| ComboData { + index: c.index as i32, + key1: SharedString::from(format!("R{}C{}", c.r1, c.c1)), + key2: SharedString::from(format!("R{}C{}", c.r2, c.c2)), + result: SharedString::from(keycode::decode_keycode(c.result)), + }).collect(); + window.global::().set_combos( + ModelRc::from(Rc::new(VecModel::from(model))) + ); + } + BgMsg::LeaderList(leader_data) => { + let model: Vec = leader_data.iter().map(|l| { + let seq: Vec = l.sequence.iter() + .map(|&k| keycode::hid_key_name(k)) + .collect(); + LeaderData { + index: l.index as i32, + sequence: SharedString::from(seq.join(" → ")), + result: SharedString::from(keycode::hid_key_name(l.result)), } - "combo" => { - let combo_data = logic::parsers::parse_combo_lines(&lines); - let model: Vec = combo_data.iter().map(|c| ComboData { - index: c.index as i32, - key1: SharedString::from(format!("R{}C{}", c.r1, c.c1)), - key2: SharedString::from(format!("R{}C{}", c.r2, c.c2)), - result: SharedString::from(keycode::decode_keycode(c.result)), - }).collect(); - window.global::().set_combos( - ModelRc::from(Rc::new(VecModel::from(model))) - ); + }).collect(); + window.global::().set_leaders( + ModelRc::from(Rc::new(VecModel::from(model))) + ); + } + BgMsg::KoList(ko_data) => { + let model: Vec = ko_data.iter().enumerate().map(|(i, ko)| { + let trig_key = keycode::hid_key_name(ko[0]); + let trig_mod = keycode::mod_name(ko[1]); + let res_key = keycode::hid_key_name(ko[2]); + let res_mod = keycode::mod_name(ko[3]); + let trigger = if ko[1] != 0 { + format!("{}+{}", trig_mod, trig_key) + } else { + trig_key + }; + let result = if ko[3] != 0 { + format!("{}+{}", res_mod, res_key) + } else { + res_key + }; + KeyOverrideData { + index: i as i32, + trigger: SharedString::from(trigger), + result: SharedString::from(result), } - "leader" => { - let leader_data = logic::parsers::parse_leader_lines(&lines); - let model: Vec = leader_data.iter().map(|l| { - let seq: Vec = l.sequence.iter() - .map(|&k| keycode::hid_key_name(k)) - .collect(); - LeaderData { - index: l.index as i32, - sequence: SharedString::from(seq.join(" → ")), - result: SharedString::from(keycode::hid_key_name(l.result)), - } - }).collect(); - window.global::().set_leaders( - ModelRc::from(Rc::new(VecModel::from(model))) - ); - } - "ko" => { - let ko_data = logic::parsers::parse_ko_lines(&lines); - let model: Vec = ko_data.iter().enumerate().map(|(i, ko)| { - let trig_key = keycode::hid_key_name(ko[0]); - let trig_mod = keycode::mod_name(ko[1]); - let res_key = keycode::hid_key_name(ko[2]); - let res_mod = keycode::mod_name(ko[3]); - let trigger = if ko[1] != 0 { - format!("{}+{}", trig_mod, trig_key) - } else { - trig_key - }; - let result = if ko[3] != 0 { - format!("{}+{}", res_mod, res_key) - } else { - res_key - }; - KeyOverrideData { - index: i as i32, - trigger: SharedString::from(trigger), - result: SharedString::from(result), - } - }).collect(); - window.global::().set_key_overrides( - ModelRc::from(Rc::new(VecModel::from(model))) - ); - } - "bt" => { - let bt_text = lines.join("\n"); - window.global::().set_bt_status(SharedString::from(bt_text)); - } - "macros" => { - let macro_data = logic::parsers::parse_macro_lines(&lines); - let model: Vec = macro_data.iter().map(|m| { - let steps_str: Vec = m.steps.iter().map(|s| { - if s.is_delay() { - format!("T({})", s.delay_ms()) - } else { - format!("D({:02X})", s.keycode) - } - }).collect(); - MacroData { - slot: m.slot as i32, - name: SharedString::from(&m.name), - steps: SharedString::from(steps_str.join(" ")), - } - }).collect(); - window.global::().set_macros( - ModelRc::from(Rc::new(VecModel::from(model))) - ); - } - "wpm" => { - if let Some(line) = lines.first() { - let wpm: u16 = line.split_whitespace() - .last() - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - window.global::().set_wpm(wpm as i32); - } - } - "tama" => { - let text = lines.join("\n"); - window.global::().set_tama_status(SharedString::from(text)); - } - "autoshift" => { - let text = lines.join(" "); - window.global::().set_autoshift_status(SharedString::from(text)); - } - _ => {} - } + }).collect(); + window.global::().set_key_overrides( + ModelRc::from(Rc::new(VecModel::from(model))) + ); + } + BgMsg::BtStatus(lines) => { + let bt_text = lines.join("\n"); + window.global::().set_bt_status(SharedString::from(bt_text)); + } + BgMsg::TamaStatus(lines) => { + let text = lines.join("\n"); + window.global::().set_tama_status(SharedString::from(text)); + } + BgMsg::AutoshiftStatus(text) => { + window.global::().set_autoshift_status(SharedString::from(text)); + } + BgMsg::Wpm(wpm) => { + window.global::().set_wpm(wpm as i32); } BgMsg::OtaProgress(progress, msg) => { let s = window.global::(); @@ -1903,6 +2346,25 @@ fn main() { } } } + BgMsg::ConfigProgress(progress, msg) => { + let s = window.global::(); + s.set_config_progress(progress); + s.set_config_status(SharedString::from(msg)); + } + BgMsg::ConfigDone(result) => { + let s = window.global::(); + s.set_config_busy(false); + match result { + Ok(msg) => { + s.set_config_progress(1.0); + s.set_config_status(SharedString::from(msg)); + } + Err(e) => { + s.set_config_progress(0.0); + s.set_config_status(SharedString::from(format!("Error: {}", e))); + } + } + } BgMsg::MacroList(macros) => { let model: Vec = macros.iter().map(|m| { let steps_str: Vec = m.steps.iter().map(|s| { @@ -1941,17 +2403,41 @@ fn main() { let tx = tx.clone(); std::thread::spawn(move || { let Ok(mut ser) = serial.try_lock() else { return }; - let lines = ser.query_command("WPM?").unwrap_or_default(); - drop(ser); - let _ = tx.send(BgMsg::TextLines("wpm".into(), lines)); + if let Ok(r) = ser.send_binary(logic::binary_protocol::cmd::WPM_QUERY, &[]) { + let wpm = if r.payload.len() >= 2 { + u16::from_le_bytes([r.payload[0], r.payload[1]]) + } else { 0 }; + let _ = tx.send(BgMsg::Wpm(wpm)); + } }); }, ); } // Keep timers alive + // Layout auto-refresh timer (5s) + let layout_timer = slint::Timer::default(); + { + let window_weak3 = window.as_weak(); + layout_timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_secs(5), + move || { + let Some(w) = window_weak3.upgrade() else { return }; + let lb = w.global::(); + if !lb.get_auto_refresh() { return; } + let path = lb.get_file_path().to_string(); + if path.is_empty() { return; } + if let Ok(json) = std::fs::read_to_string(&path) { + populate_layout_preview(&w, &json); + } + }, + ); + } + let _keep_timer = timer; let _keep_wpm = wpm_timer; + let _keep_layout = layout_timer; window.run().unwrap(); } } diff --git a/ui/globals.slint b/ui/globals.slint index 2c35262..0c87cca 100644 --- a/ui/globals.slint +++ b/ui/globals.slint @@ -81,6 +81,12 @@ export global SettingsBridge { in property ota-flashing: false; callback ota-browse(); callback ota-start(); + // Config import/export + in property config-status: ""; + in property config-busy: false; + in property 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 content-width: 860px; + in property content-height: 360px; + in property status: ""; + in property json-text: ""; + in-out property file-path: ""; + in-out property auto-refresh: false; + callback load-from-file(); + callback load-from-keyboard(); + callback load-json(string); + callback export-json(); +} + // ---- Key Selector ---- export struct KeyEntry { diff --git a/ui/main.slint b/ui/main.slint index 4018240..b851c15 100644 --- a/ui/main.slint +++ b/ui/main.slint @@ -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 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 { } diff --git a/ui/tabs/tab_settings.slint b/ui/tabs/tab_settings.slint index e05a88f..9631024 100644 --- a/ui/tabs/tab_settings.slint +++ b/ui/tabs/tab_settings.slint @@ -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; diff --git a/ui/tabs/tab_tools.slint b/ui/tabs/tab_tools.slint new file mode 100644 index 0000000..99b7cb0 --- /dev/null +++ b/ui/tabs/tab_tools.slint @@ -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 scale-x: self.width / LayoutBridge.content-width; + property scale-y: self.height / LayoutBridge.content-height; + property scale: min(scale-x, scale-y) * 0.95; + property offset-x: (self.width - LayoutBridge.content-width * scale) / 2; + property 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; + } + } + } + } + } +}