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:
parent
88b51bd399
commit
d1d10b7d73
10 changed files with 1401 additions and 736 deletions
391
default.json
391
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 } }
|
||||
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
65
src/logic/config_io.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#[allow(dead_code)]
|
||||
pub mod binary_protocol;
|
||||
pub mod config_io;
|
||||
#[allow(dead_code)]
|
||||
pub mod flasher;
|
||||
#[allow(dead_code)]
|
||||
|
|
|
|||
908
src/main.rs
908
src/main.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 { }
|
||||
|
|
|
|||
|
|
@ -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
309
ui/tabs/tab_tools.slint
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue