Compare commits
No commits in common. "35950661fff385dc324bcf3108fc3942f85691ed" and "476885576a42e4bba98b8b8fa726d9bb7c2b1a8e" have entirely different histories.
35950661ff
...
476885576a
59 changed files with 7329 additions and 3850 deletions
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -16,6 +16,9 @@ jobs:
|
|||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
name: KeSp_controller-windows-x86_64.exe
|
||||
- os: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
name: KeSp_controller-macos-intel
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
name: KeSp_controller-macos-arm64
|
||||
|
|
@ -63,4 +66,5 @@ jobs:
|
|||
files: |
|
||||
KeSp_controller-linux-x86_64/KeSp_controller-linux-x86_64
|
||||
KeSp_controller-windows-x86_64.exe/KeSp_controller-windows-x86_64.exe
|
||||
KeSp_controller-macos-intel/KeSp_controller-macos-intel
|
||||
KeSp_controller-macos-arm64/KeSp_controller-macos-arm64
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,5 +1 @@
|
|||
/target
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@
|
|||
name = "KeSp_controller"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0"
|
||||
description = "Cross-platform configurator for the KeSp split ergonomic keyboard"
|
||||
repository = "https://github.com/mornepousse/KeSp_controller"
|
||||
|
||||
[dependencies]
|
||||
slint = "1"
|
||||
|
|
|
|||
22
LICENSE
22
LICENSE
|
|
@ -1,22 +0,0 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
For the complete license text, see <https://www.gnu.org/licenses/gpl-3.0.txt>
|
||||
63
README.md
63
README.md
|
|
@ -1,63 +0,0 @@
|
|||
# KeSp Controller
|
||||
|
||||
Cross-platform configurator for the KeSp split ergonomic keyboard.
|
||||
|
||||
Built with [Rust](https://www.rust-lang.org/) and [Slint](https://slint.dev/) UI framework.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Keymap editor** with visual keyboard layout (loaded from firmware)
|
||||
- **Key selector** with categorized grid, Mod-Tap/Layer-Tap builders, hex input
|
||||
- **Heatmap overlay** showing key press frequency (blue to red gradient)
|
||||
- **Layer management** with switch, rename, and active indicator
|
||||
- **Tap Dance** editing (4 actions per slot)
|
||||
- **Combos** creation with visual key picker
|
||||
- **Key Overrides** with modifier checkboxes (Ctrl/Shift/Alt)
|
||||
- **Leader Keys** with sequence builder
|
||||
- **Macros** with visual step builder (key presses + delays)
|
||||
- **Statistics** (hand balance, finger load, row usage, top keys, bigrams)
|
||||
- **OTA firmware update** via USB (no programming cable needed)
|
||||
- **ESP32 flasher** (esptool-like, via programming port)
|
||||
- **Settings** with keyboard layout selector (QWERTY, AZERTY, DVORAK, etc.)
|
||||
- **Dracula theme** throughout
|
||||
|
||||
## Download
|
||||
|
||||
Pre-built binaries for Linux, Windows, and macOS are available on the [Releases](https://github.com/mornepousse/KeSp_controller/releases) page.
|
||||
|
||||
## Build from source
|
||||
|
||||
### Requirements
|
||||
|
||||
- Rust toolchain (1.75+)
|
||||
- Linux: `libudev-dev libfontconfig1-dev`
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Binary will be at `target/release/KeSp_controller`.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Plug in your KeSp keyboard via USB
|
||||
2. Launch KeSp Controller
|
||||
3. The app auto-connects to the keyboard
|
||||
4. Use the tabs to configure: Keymap, Advanced, Macros, Stats, Settings, Flash
|
||||
|
||||
## Keyboard compatibility
|
||||
|
||||
Designed for the KeSp/KaSe split keyboard with:
|
||||
- USB CDC serial (VID: 0xCAFE, PID: 0x4001)
|
||||
- Binary protocol v2
|
||||
- ESP32-S3 MCU
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0 - See [LICENSE](LICENSE)
|
||||
|
||||
Made with [Slint](https://slint.dev/)
|
||||
389
default.json
389
default.json
|
|
@ -1,133 +1,260 @@
|
|||
{
|
||||
"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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
"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 } }
|
||||
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
56
flake.nix
56
flake.nix
|
|
@ -1,56 +0,0 @@
|
|||
{
|
||||
description = "KeSp split keyboard configurator — Slint UI";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
cmake
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
fontconfig
|
||||
libxkbcommon
|
||||
wayland
|
||||
udev
|
||||
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
xorg.libX11
|
||||
xorg.libXcursor
|
||||
xorg.libXrandr
|
||||
xorg.libXi
|
||||
];
|
||||
in
|
||||
{
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "kesp-controller";
|
||||
version = "1.0.0";
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
|
||||
inherit nativeBuildInputs buildInputs;
|
||||
|
||||
# Slint needs to find fontconfig at runtime
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Cross-platform configurator for the KeSp split ergonomic keyboard";
|
||||
license = licenses.gpl3Only;
|
||||
mainProgram = "KeSp_controller";
|
||||
};
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
inherit nativeBuildInputs buildInputs;
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
|
||||
packages = with pkgs; [ cargo rustc rust-analyzer clippy ];
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -31,10 +31,8 @@ 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
|
||||
|
|
@ -66,7 +64,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 = 0x95;
|
||||
pub const TRILAYER_SET: u8 = 0x94;
|
||||
|
||||
// Tamagotchi
|
||||
pub const TAMA_QUERY: u8 = 0xA0;
|
||||
|
|
@ -78,9 +76,6 @@ pub mod cmd {
|
|||
pub const TAMA_MEDICINE: u8 = 0xA6;
|
||||
pub const TAMA_SAVE: u8 = 0xA7;
|
||||
|
||||
// Diagnostics
|
||||
pub const MATRIX_TEST: u8 = 0xB0;
|
||||
|
||||
// OTA
|
||||
pub const OTA_START: u8 = 0xF0;
|
||||
pub const OTA_DATA: u8 = 0xF1;
|
||||
|
|
@ -194,32 +189,6 @@ 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 {
|
||||
|
|
@ -288,21 +257,3 @@ pub fn parse_kr(data: &[u8]) -> Result<(KrResponse, usize), String> {
|
|||
let consumed = payload_end + 1 - pos;
|
||||
Ok((KrResponse { cmd, status, payload }, consumed))
|
||||
}
|
||||
|
||||
/// Parse all KR frames from a byte buffer (for unsolicited events).
|
||||
pub fn parse_all_kr(data: &[u8]) -> Vec<KrResponse> {
|
||||
let mut results = Vec::new();
|
||||
let mut offset = 0;
|
||||
while offset < data.len() {
|
||||
match parse_kr(&data[offset..]) {
|
||||
Ok((resp, consumed)) => {
|
||||
offset += consumed;
|
||||
results.push(resp);
|
||||
}
|
||||
Err(_) => {
|
||||
offset += 1; // skip garbage byte
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
501
original-src/flasher.rs
Normal file
501
original-src/flasher.rs
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
/// ESP32 ROM bootloader flasher via serial (CH340/CP2102 programming port).
|
||||
/// Implements minimal SLIP-framed bootloader protocol for firmware flashing
|
||||
/// without requiring esptool.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use serialport::SerialPort;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::mpsc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ==================== SLIP framing ====================
|
||||
|
||||
const SLIP_END: u8 = 0xC0;
|
||||
const SLIP_ESC: u8 = 0xDB;
|
||||
const SLIP_ESC_END: u8 = 0xDC;
|
||||
const SLIP_ESC_ESC: u8 = 0xDD;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn slip_encode(data: &[u8]) -> Vec<u8> {
|
||||
let mut frame = Vec::with_capacity(data.len() + 10);
|
||||
frame.push(SLIP_END);
|
||||
for &byte in data {
|
||||
match byte {
|
||||
SLIP_END => {
|
||||
frame.push(SLIP_ESC);
|
||||
frame.push(SLIP_ESC_END);
|
||||
}
|
||||
SLIP_ESC => {
|
||||
frame.push(SLIP_ESC);
|
||||
frame.push(SLIP_ESC_ESC);
|
||||
}
|
||||
_ => frame.push(byte),
|
||||
}
|
||||
}
|
||||
frame.push(SLIP_END);
|
||||
frame
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn slip_decode(frame: &[u8]) -> Vec<u8> {
|
||||
let mut data = Vec::with_capacity(frame.len());
|
||||
let mut escaped = false;
|
||||
for &byte in frame {
|
||||
if escaped {
|
||||
match byte {
|
||||
SLIP_ESC_END => data.push(SLIP_END),
|
||||
SLIP_ESC_ESC => data.push(SLIP_ESC),
|
||||
_ => data.push(byte),
|
||||
}
|
||||
escaped = false;
|
||||
} else if byte == SLIP_ESC {
|
||||
escaped = true;
|
||||
} else if byte != SLIP_END {
|
||||
data.push(byte);
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
// ==================== Bootloader commands ====================
|
||||
|
||||
const CMD_SYNC: u8 = 0x08;
|
||||
const CMD_CHANGE_BAUDRATE: u8 = 0x0F;
|
||||
const CMD_SPI_ATTACH: u8 = 0x0D;
|
||||
const CMD_FLASH_BEGIN: u8 = 0x02;
|
||||
const CMD_FLASH_DATA: u8 = 0x03;
|
||||
const CMD_FLASH_END: u8 = 0x04;
|
||||
|
||||
const FLASH_BLOCK_SIZE: u32 = 1024;
|
||||
const INITIAL_BAUD: u32 = 115200;
|
||||
const FLASH_BAUD: u32 = 460800;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn xor_checksum(data: &[u8]) -> u32 {
|
||||
let mut chk: u8 = 0xEF;
|
||||
for &b in data {
|
||||
chk ^= b;
|
||||
}
|
||||
chk as u32
|
||||
}
|
||||
|
||||
/// Build a bootloader command packet (before SLIP encoding).
|
||||
/// Format: [0x00][cmd][size:u16 LE][checksum:u32 LE][data...]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_command(cmd: u8, data: &[u8], checksum: u32) -> Vec<u8> {
|
||||
let size = data.len() as u16;
|
||||
let mut pkt = Vec::with_capacity(8 + data.len());
|
||||
pkt.push(0x00); // direction: command
|
||||
pkt.push(cmd);
|
||||
pkt.push((size & 0xFF) as u8);
|
||||
pkt.push((size >> 8) as u8);
|
||||
pkt.push((checksum & 0xFF) as u8);
|
||||
pkt.push(((checksum >> 8) & 0xFF) as u8);
|
||||
pkt.push(((checksum >> 16) & 0xFF) as u8);
|
||||
pkt.push(((checksum >> 24) & 0xFF) as u8);
|
||||
pkt.extend_from_slice(data);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Extract complete SLIP frames from a byte buffer.
|
||||
/// Returns (frames, remaining_bytes_not_consumed).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn extract_slip_frames(raw: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut frames = Vec::new();
|
||||
let mut in_frame = false;
|
||||
let mut current = Vec::new();
|
||||
|
||||
for &byte in raw {
|
||||
if byte == SLIP_END {
|
||||
if in_frame && !current.is_empty() {
|
||||
// End of frame
|
||||
frames.push(current.clone());
|
||||
current.clear();
|
||||
in_frame = false;
|
||||
} else {
|
||||
// Start of frame (or consecutive 0xC0)
|
||||
in_frame = true;
|
||||
current.clear();
|
||||
}
|
||||
} else if in_frame {
|
||||
current.push(byte);
|
||||
}
|
||||
// If !in_frame and byte != SLIP_END, it's garbage — skip
|
||||
}
|
||||
frames
|
||||
}
|
||||
|
||||
/// Send a command and receive a valid response.
|
||||
/// Handles boot log garbage and multiple SYNC responses.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn send_command(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
cmd: u8,
|
||||
data: &[u8],
|
||||
checksum: u32,
|
||||
timeout_ms: u64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let pkt = build_command(cmd, data, checksum);
|
||||
let frame = slip_encode(&pkt);
|
||||
|
||||
port.write_all(&frame)
|
||||
.map_err(|e| format!("Write error: {}", e))?;
|
||||
port.flush()
|
||||
.map_err(|e| format!("Flush error: {}", e))?;
|
||||
|
||||
// Read bytes and extract SLIP frames, looking for a valid response
|
||||
let mut raw = Vec::new();
|
||||
let mut buf = [0u8; 512];
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
loop {
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed > timeout {
|
||||
let got = if raw.is_empty() {
|
||||
"nothing".to_string()
|
||||
} else {
|
||||
format!("{} raw bytes, no valid response", raw.len())
|
||||
};
|
||||
return Err(format!("Response timeout (got {})", got));
|
||||
}
|
||||
|
||||
let read_result = port.read(&mut buf);
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
raw.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a valid response in accumulated data
|
||||
let frames = extract_slip_frames(&raw);
|
||||
for slip_data in &frames {
|
||||
let decoded = slip_decode(slip_data);
|
||||
|
||||
if decoded.len() < 8 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let direction = decoded[0];
|
||||
let resp_cmd = decoded[1];
|
||||
|
||||
if direction != 0x01 || resp_cmd != cmd {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ROM bootloader status is at offset 8 (right after 8-byte header)
|
||||
// Format: [dir][cmd][size:u16][value:u32][status][error][pad][pad]
|
||||
if decoded.len() >= 10 {
|
||||
let status = decoded[8];
|
||||
let error = decoded[9];
|
||||
if status != 0 {
|
||||
return Err(format!("Bootloader error: cmd=0x{:02X} status={}, error={} (0x{:02X})",
|
||||
cmd, status, error, error));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Bootloader entry ====================
|
||||
|
||||
/// Toggle DTR/RTS to reset ESP32 into bootloader mode.
|
||||
/// Standard auto-reset circuit: DTR→EN, RTS→GPIO0.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn enter_bootloader(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
// Hold GPIO0 low (RTS=true) while pulsing EN (DTR)
|
||||
port.write_data_terminal_ready(false)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
port.write_request_to_send(true)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
// Release EN (DTR=true) while keeping GPIO0 low
|
||||
port.write_data_terminal_ready(true)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
port.write_request_to_send(false)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
|
||||
// Release all
|
||||
port.write_data_terminal_ready(false)
|
||||
.map_err(|e| format!("DTR error: {}", e))?;
|
||||
|
||||
// Drain any boot message
|
||||
let _ = port.clear(serialport::ClearBuffer::All);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== High-level commands ====================
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn sync(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
// SYNC payload: [0x07, 0x07, 0x12, 0x20] + 32 x 0x55
|
||||
let mut payload = vec![0x07, 0x07, 0x12, 0x20];
|
||||
payload.extend_from_slice(&[0x55; 32]);
|
||||
|
||||
for attempt in 0..10 {
|
||||
let result = send_command(port, CMD_SYNC, &payload, 0, 500);
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) if attempt < 9 => {
|
||||
// Drain any pending data before retry
|
||||
let _ = port.clear(serialport::ClearBuffer::Input);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => return Err(format!("SYNC failed after 10 attempts: {}", e)),
|
||||
}
|
||||
}
|
||||
Err("SYNC failed".into())
|
||||
}
|
||||
|
||||
/// Tell the bootloader to switch to a faster baud rate, then reconnect.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn change_baudrate(port: &mut Box<dyn SerialPort>, new_baud: u32) -> Result<(), String> {
|
||||
// Payload: [new_baud:u32 LE][old_baud:u32 LE] (old_baud=0 means "current")
|
||||
let mut payload = Vec::with_capacity(8);
|
||||
payload.extend_from_slice(&new_baud.to_le_bytes());
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
|
||||
send_command(port, CMD_CHANGE_BAUDRATE, &payload, 0, 3000)?;
|
||||
|
||||
// Switch host side to new baud
|
||||
port.set_baud_rate(new_baud)
|
||||
.map_err(|e| format!("Set baud error: {}", e))?;
|
||||
|
||||
// Small delay for baud switch to take effect
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let _ = port.clear(serialport::ClearBuffer::All);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spi_attach(port: &mut Box<dyn SerialPort>) -> Result<(), String> {
|
||||
let payload = [0u8; 8];
|
||||
send_command(port, CMD_SPI_ATTACH, &payload, 0, 3000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_begin(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
offset: u32,
|
||||
total_size: u32,
|
||||
block_size: u32,
|
||||
) -> Result<(), String> {
|
||||
let num_blocks = (total_size + block_size - 1) / block_size;
|
||||
|
||||
let mut payload = Vec::with_capacity(20);
|
||||
// erase_size
|
||||
payload.extend_from_slice(&total_size.to_le_bytes());
|
||||
// num_blocks
|
||||
payload.extend_from_slice(&num_blocks.to_le_bytes());
|
||||
// block_size
|
||||
payload.extend_from_slice(&block_size.to_le_bytes());
|
||||
// offset
|
||||
payload.extend_from_slice(&offset.to_le_bytes());
|
||||
// encrypted (ESP32-S3 requires this 5th field — 0 = not encrypted)
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
|
||||
// FLASH_BEGIN can take a while (flash erase) — long timeout
|
||||
send_command(port, CMD_FLASH_BEGIN, &payload, 0, 30_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_data(
|
||||
port: &mut Box<dyn SerialPort>,
|
||||
seq: u32,
|
||||
data: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let data_len = data.len() as u32;
|
||||
|
||||
let mut payload = Vec::with_capacity(16 + data.len());
|
||||
// data length
|
||||
payload.extend_from_slice(&data_len.to_le_bytes());
|
||||
// sequence number
|
||||
payload.extend_from_slice(&seq.to_le_bytes());
|
||||
// reserved (2 x u32)
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
// data
|
||||
payload.extend_from_slice(data);
|
||||
|
||||
let checksum = xor_checksum(data);
|
||||
send_command(port, CMD_FLASH_DATA, &payload, checksum, 10_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn flash_end(port: &mut Box<dyn SerialPort>, reboot: bool) -> Result<(), String> {
|
||||
let flag: u32 = if reboot { 0 } else { 1 };
|
||||
let payload = flag.to_le_bytes();
|
||||
// FLASH_END might not get a response if device reboots
|
||||
let _ = send_command(port, CMD_FLASH_END, &payload, 0, 2000);
|
||||
|
||||
if reboot {
|
||||
// Hard reset: toggle RTS to pulse EN pin (like esptool --after hard_reset)
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
port.write_request_to_send(true)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
port.write_request_to_send(false)
|
||||
.map_err(|e| format!("RTS error: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Main entry point ====================
|
||||
|
||||
/// Flash firmware to ESP32 via programming port (CH340/CP2102).
|
||||
/// Sends progress updates via the channel as (progress_0_to_1, status_message).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn flash_firmware(
|
||||
port_name: &str,
|
||||
firmware: &[u8],
|
||||
offset: u32,
|
||||
tx: &mpsc::Sender<super::ui::BgResult>,
|
||||
) -> Result<(), String> {
|
||||
let send_progress = |progress: f32, msg: String| {
|
||||
let _ = tx.send(super::ui::BgResult::OtaProgress(progress, msg));
|
||||
};
|
||||
|
||||
send_progress(0.0, "Opening port...".into());
|
||||
|
||||
let builder = serialport::new(port_name, INITIAL_BAUD);
|
||||
let builder_timeout = builder.timeout(Duration::from_millis(500));
|
||||
let mut port = builder_timeout.open()
|
||||
.map_err(|e| format!("Cannot open {}: {}", port_name, e))?;
|
||||
|
||||
// Step 1: Enter bootloader
|
||||
send_progress(0.0, "Resetting into bootloader...".into());
|
||||
enter_bootloader(&mut port)?;
|
||||
|
||||
// Step 2: Sync at 115200
|
||||
send_progress(0.0, "Syncing with bootloader...".into());
|
||||
sync(&mut port)?;
|
||||
send_progress(0.02, "Bootloader sync OK".into());
|
||||
|
||||
// Step 3: Switch to 460800 baud for faster flashing
|
||||
send_progress(0.03, format!("Switching to {} baud...", FLASH_BAUD));
|
||||
change_baudrate(&mut port, FLASH_BAUD)?;
|
||||
send_progress(0.04, format!("Baud: {}", FLASH_BAUD));
|
||||
|
||||
// Step 4: SPI attach
|
||||
send_progress(0.05, "Attaching SPI flash...".into());
|
||||
spi_attach(&mut port)?;
|
||||
|
||||
// Step 5: Flash begin (this erases the flash — can take several seconds)
|
||||
let total_size = firmware.len() as u32;
|
||||
let num_blocks = (total_size + FLASH_BLOCK_SIZE - 1) / FLASH_BLOCK_SIZE;
|
||||
send_progress(0.05, format!("Erasing flash ({} KB)...", total_size / 1024));
|
||||
flash_begin(&mut port, offset, total_size, FLASH_BLOCK_SIZE)?;
|
||||
send_progress(0.10, "Flash erased, writing...".into());
|
||||
|
||||
// Step 6: Flash data blocks
|
||||
for (i, chunk) in firmware.chunks(FLASH_BLOCK_SIZE as usize).enumerate() {
|
||||
// Pad last block to block_size
|
||||
let mut block = chunk.to_vec();
|
||||
let pad_needed = FLASH_BLOCK_SIZE as usize - block.len();
|
||||
if pad_needed > 0 {
|
||||
block.extend(std::iter::repeat(0xFF).take(pad_needed));
|
||||
}
|
||||
|
||||
flash_data(&mut port, i as u32, &block)?;
|
||||
|
||||
let blocks_done = (i + 1) as f32;
|
||||
let total_blocks = num_blocks as f32;
|
||||
let progress = 0.10 + 0.85 * (blocks_done / total_blocks);
|
||||
let msg = format!("Writing block {}/{} ({} KB / {} KB)",
|
||||
i + 1, num_blocks,
|
||||
((i + 1) as u32 * FLASH_BLOCK_SIZE).min(total_size) / 1024,
|
||||
total_size / 1024);
|
||||
send_progress(progress, msg);
|
||||
}
|
||||
|
||||
// Step 7: Flash end + reboot
|
||||
send_progress(0.97, "Finalizing...".into());
|
||||
flash_end(&mut port, true)?;
|
||||
|
||||
send_progress(1.0, format!("Flash OK — {} KB written at 0x{:X}", total_size / 1024, offset));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slip_encode_no_special() {
|
||||
let data = vec![0x01, 0x02, 0x03];
|
||||
let encoded = slip_encode(&data);
|
||||
assert_eq!(encoded, vec![0xC0, 0x01, 0x02, 0x03, 0xC0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slip_encode_with_end_byte() {
|
||||
let data = vec![0x01, 0xC0, 0x03];
|
||||
let encoded = slip_encode(&data);
|
||||
assert_eq!(encoded, vec![0xC0, 0x01, 0xDB, 0xDC, 0x03, 0xC0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slip_encode_with_esc_byte() {
|
||||
let data = vec![0x01, 0xDB, 0x03];
|
||||
let encoded = slip_encode(&data);
|
||||
assert_eq!(encoded, vec![0xC0, 0x01, 0xDB, 0xDD, 0x03, 0xC0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slip_roundtrip() {
|
||||
let original = vec![0xC0, 0xDB, 0x00, 0xFF, 0xC0];
|
||||
let encoded = slip_encode(&original);
|
||||
let decoded = slip_decode(&encoded);
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_checksum_basic() {
|
||||
let data = vec![0x01, 0x02, 0x03];
|
||||
let chk = xor_checksum(&data);
|
||||
let expected = 0xEF ^ 0x01 ^ 0x02 ^ 0x03;
|
||||
assert_eq!(chk, expected as u32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xor_checksum_empty() {
|
||||
let chk = xor_checksum(&[]);
|
||||
assert_eq!(chk, 0xEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_command_format() {
|
||||
let data = vec![0xAA, 0xBB];
|
||||
let pkt = build_command(0x08, &data, 0x12345678);
|
||||
assert_eq!(pkt[0], 0x00); // direction
|
||||
assert_eq!(pkt[1], 0x08); // command
|
||||
assert_eq!(pkt[2], 0x02); // size low
|
||||
assert_eq!(pkt[3], 0x00); // size high
|
||||
assert_eq!(pkt[4], 0x78); // checksum byte 0
|
||||
assert_eq!(pkt[5], 0x56); // checksum byte 1
|
||||
assert_eq!(pkt[6], 0x34); // checksum byte 2
|
||||
assert_eq!(pkt[7], 0x12); // checksum byte 3
|
||||
assert_eq!(pkt[8], 0xAA); // data
|
||||
assert_eq!(pkt[9], 0xBB);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ pub fn decode_keycode(raw: u16) -> String {
|
|||
|
||||
// --- MACRO: 0x1500..=0x2800, low byte == 0 ---
|
||||
if raw >= 0x1500 && raw <= 0x2800 && (raw & 0xFF) == 0 {
|
||||
let idx = (raw >> 8) - 0x15;
|
||||
let idx = (raw >> 8) - 0x14;
|
||||
return format!("M{idx}");
|
||||
}
|
||||
|
||||
286
original-src/layout.rs
Normal file
286
original-src/layout.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
use serde_json::Value;
|
||||
|
||||
/// A keycap with computed absolute position.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct KeycapPos {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
pub angle: f32, // degrees
|
||||
}
|
||||
|
||||
const KEY_SIZE: f32 = 50.0;
|
||||
const KEY_GAP: f32 = 4.0;
|
||||
|
||||
/// Parse a layout JSON string into absolute key positions.
|
||||
pub fn parse_json(json: &str) -> Result<Vec<KeycapPos>, String> {
|
||||
let val: Value = serde_json::from_str(json)
|
||||
.map_err(|e| format!("Invalid layout JSON: {}", e))?;
|
||||
let mut keys = Vec::new();
|
||||
walk(&val, 0.0, 0.0, 0.0, &mut keys);
|
||||
if keys.is_empty() {
|
||||
return Err("No keys found in layout".into());
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Default layout embedded at compile time.
|
||||
pub fn default_layout() -> Vec<KeycapPos> {
|
||||
let json = include_str!("../default.json");
|
||||
parse_json(json).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn walk(node: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match node.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
for (key, val) in obj {
|
||||
let key_str = key.as_str();
|
||||
match key_str {
|
||||
"Group" => walk_group(val, ox, oy, parent_angle, out),
|
||||
"Line" => walk_line(val, ox, oy, parent_angle, out),
|
||||
"Keycap" => walk_keycap(val, ox, oy, parent_angle, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_margin(val: &Value) -> (f32, f32, f32, f32) {
|
||||
let as_str = val.as_str();
|
||||
if let Some(s) = as_str {
|
||||
let split = s.split(',');
|
||||
let parts: Vec<f32> = split
|
||||
.filter_map(|p| {
|
||||
let trimmed = p.trim();
|
||||
let parsed = trimmed.parse().ok();
|
||||
parsed
|
||||
})
|
||||
.collect();
|
||||
let has_four_parts = parts.len() == 4;
|
||||
if has_four_parts {
|
||||
return (parts[0], parts[1], parts[2], parts[3]);
|
||||
}
|
||||
}
|
||||
(0.0, 0.0, 0.0, 0.0)
|
||||
}
|
||||
|
||||
fn parse_angle(val: &Value) -> f32 {
|
||||
let rotate_transform = val.get("RotateTransform");
|
||||
let angle_val = rotate_transform
|
||||
.and_then(|rt| rt.get("Angle"));
|
||||
let angle_f64 = angle_val
|
||||
.and_then(|a| a.as_f64());
|
||||
let angle = angle_f64.unwrap_or(0.0) as f32;
|
||||
angle
|
||||
}
|
||||
|
||||
fn walk_group(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let gx = ox + ml;
|
||||
let gy = oy + mt;
|
||||
|
||||
let children_val = obj.get("Children");
|
||||
let children_array = children_val
|
||||
.and_then(|c| c.as_array());
|
||||
if let Some(children) = children_array {
|
||||
let combined_angle = parent_angle + angle;
|
||||
for child in children {
|
||||
walk(child, gx, gy, combined_angle, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_line(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
let total_angle = parent_angle + angle;
|
||||
|
||||
let orientation_val = obj.get("Orientation");
|
||||
let orientation_str = orientation_val
|
||||
.and_then(|o| o.as_str())
|
||||
.unwrap_or("Vertical");
|
||||
let horiz = orientation_str == "Horizontal";
|
||||
|
||||
let lx = ox + ml;
|
||||
let ly = oy + mt;
|
||||
|
||||
let rad = total_angle.to_radians();
|
||||
let cos_a = rad.cos();
|
||||
let sin_a = rad.sin();
|
||||
|
||||
let mut cursor = 0.0f32;
|
||||
|
||||
let children_val = obj.get("Children");
|
||||
let children_array = children_val
|
||||
.and_then(|c| c.as_array());
|
||||
if let Some(children) = children_array {
|
||||
for child in children {
|
||||
let (cx, cy) = if horiz {
|
||||
let x = lx + cursor * cos_a;
|
||||
let y = ly + cursor * sin_a;
|
||||
(x, y)
|
||||
} else {
|
||||
let x = lx - cursor * sin_a;
|
||||
let y = ly + cursor * cos_a;
|
||||
(x, y)
|
||||
};
|
||||
|
||||
let child_size = measure(child, horiz);
|
||||
walk(child, cx, cy, total_angle, out);
|
||||
cursor += child_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure a child's extent along the parent's main axis.
|
||||
fn measure(node: &Value, horiz: bool) -> f32 {
|
||||
let obj = match node.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
|
||||
for (key, val) in obj {
|
||||
let key_str = key.as_str();
|
||||
match key_str {
|
||||
"Keycap" => {
|
||||
let width_val = val.get("Width");
|
||||
let width_f64 = width_val
|
||||
.and_then(|v| v.as_f64());
|
||||
let w = width_f64.unwrap_or(KEY_SIZE as f64) as f32;
|
||||
let extent = if horiz {
|
||||
w + KEY_GAP
|
||||
} else {
|
||||
KEY_SIZE + KEY_GAP
|
||||
};
|
||||
return extent;
|
||||
}
|
||||
"Line" => {
|
||||
let sub = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
let sub_orientation = sub.get("Orientation");
|
||||
let sub_orient_str = sub_orientation
|
||||
.and_then(|o| o.as_str())
|
||||
.unwrap_or("Vertical");
|
||||
let sub_horiz = sub_orient_str == "Horizontal";
|
||||
|
||||
let sub_children_val = sub.get("Children");
|
||||
let sub_children_array = sub_children_val
|
||||
.and_then(|c| c.as_array());
|
||||
let children = sub_children_array
|
||||
.map(|a| a.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
let same_direction = sub_horiz == horiz;
|
||||
let content: f32 = if same_direction {
|
||||
// Same direction: sum
|
||||
children
|
||||
.iter()
|
||||
.map(|c| measure(c, sub_horiz))
|
||||
.sum()
|
||||
} else {
|
||||
// Cross direction: max
|
||||
children
|
||||
.iter()
|
||||
.map(|c| measure(c, horiz))
|
||||
.fold(0.0f32, f32::max)
|
||||
};
|
||||
|
||||
return content;
|
||||
}
|
||||
"Group" => {
|
||||
let sub = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
let sub_children_val = sub.get("Children");
|
||||
let sub_children_array = sub_children_val
|
||||
.and_then(|c| c.as_array());
|
||||
let children = sub_children_array
|
||||
.map(|a| a.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let max_extent = children
|
||||
.iter()
|
||||
.map(|c| measure(c, horiz))
|
||||
.fold(0.0f32, f32::max);
|
||||
return max_extent;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
fn walk_keycap(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let col_val = obj.get("Column");
|
||||
let col_u64 = col_val
|
||||
.and_then(|v| v.as_u64());
|
||||
let col = col_u64.unwrap_or(0) as usize;
|
||||
|
||||
let row_val = obj.get("Row");
|
||||
let row_u64 = row_val
|
||||
.and_then(|v| v.as_u64());
|
||||
let row = row_u64.unwrap_or(0) as usize;
|
||||
|
||||
let width_val = obj.get("Width");
|
||||
let width_f64 = width_val
|
||||
.and_then(|v| v.as_f64());
|
||||
let w = width_f64.unwrap_or(KEY_SIZE as f64) as f32;
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let total_angle = parent_angle + angle;
|
||||
|
||||
out.push(KeycapPos {
|
||||
row,
|
||||
col,
|
||||
x: ox + ml,
|
||||
y: oy + mt,
|
||||
w,
|
||||
h: KEY_SIZE,
|
||||
angle: total_angle,
|
||||
});
|
||||
}
|
||||
900
original-src/parsers.rs
Normal file
900
original-src/parsers.rs
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
/// Parsing functions for firmware text and binary responses.
|
||||
/// Separated for testability.
|
||||
|
||||
/// Keyboard physical dimensions (must match firmware).
|
||||
pub const ROWS: usize = 5;
|
||||
pub const COLS: usize = 13;
|
||||
|
||||
/// Parse "TD0: 04,05,06,29" lines into an array of 8 tap dance slots.
|
||||
/// Each slot has 4 actions: [1-tap, 2-tap, 3-tap, hold].
|
||||
pub fn parse_td_lines(lines: &[String]) -> Vec<[u16; 4]> {
|
||||
let mut result = vec![[0u16; 4]; 8];
|
||||
|
||||
for line in lines {
|
||||
// Only process lines starting with "TD"
|
||||
let starts_with_td = line.starts_with("TD");
|
||||
if !starts_with_td {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the colon separator: "TD0: ..."
|
||||
let colon = match line.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract the index between "TD" and ":"
|
||||
let index_str = &line[2..colon];
|
||||
let idx: usize = match index_str.parse() {
|
||||
Ok(i) if i < 8 => i,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Parse the comma-separated hex values after the colon
|
||||
let after_colon = &line[colon + 1..];
|
||||
let trimmed_values = after_colon.trim();
|
||||
let split_parts = trimmed_values.split(',');
|
||||
let vals: Vec<u16> = split_parts
|
||||
.filter_map(|s| {
|
||||
let trimmed_part = s.trim();
|
||||
u16::from_str_radix(trimmed_part, 16).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// We need exactly 4 values
|
||||
let has_four_values = vals.len() == 4;
|
||||
if has_four_values {
|
||||
result[idx] = [vals[0], vals[1], vals[2], vals[3]];
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse KO (Key Override) lines into arrays of [trigger, mod, result, res_mod].
|
||||
/// Format: "KO0: trigger=2A mod=02 -> result=4C resmod=00"
|
||||
pub fn parse_ko_lines(lines: &[String]) -> Vec<[u8; 4]> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
// Only process lines starting with "KO"
|
||||
let starts_with_ko = line.starts_with("KO");
|
||||
if !starts_with_ko {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Helper: extract hex value after a keyword like "trigger="
|
||||
let parse_hex = |key: &str| -> u8 {
|
||||
let key_position = line.find(key);
|
||||
|
||||
let after_key = match key_position {
|
||||
Some(i) => {
|
||||
let rest = &line[i + key.len()..];
|
||||
let first_token = rest.split_whitespace().next();
|
||||
first_token
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let parsed_value = match after_key {
|
||||
Some(s) => {
|
||||
let without_prefix = s.trim_start_matches("0x");
|
||||
u8::from_str_radix(without_prefix, 16).ok()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
parsed_value.unwrap_or(0)
|
||||
};
|
||||
|
||||
let trigger = parse_hex("trigger=");
|
||||
let modifier = parse_hex("mod=");
|
||||
let result_key = parse_hex("result=");
|
||||
let result_mod = parse_hex("resmod=");
|
||||
|
||||
result.push([trigger, modifier, result_key, result_mod]);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse heatmap lines (KEYSTATS? response).
|
||||
/// Format: "R0: 100 50 30 20 10 5 0 15 25 35 45 55 65"
|
||||
/// Returns (data[5][13], max_value).
|
||||
pub fn parse_heatmap_lines(lines: &[String]) -> (Vec<Vec<u32>>, u32) {
|
||||
let mut data: Vec<Vec<u32>> = vec![vec![0u32; COLS]; ROWS];
|
||||
let mut max = 0u32;
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Only process lines starting with "R"
|
||||
if !trimmed.starts_with('R') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the colon
|
||||
let colon = match trimmed.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract row number between "R" and ":"
|
||||
let row_str = &trimmed[1..colon];
|
||||
let row: usize = match row_str.parse() {
|
||||
Ok(r) if r < ROWS => r,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Parse space-separated values after the colon
|
||||
let values_str = &trimmed[colon + 1..];
|
||||
for (col, token) in values_str.split_whitespace().enumerate() {
|
||||
if col >= COLS {
|
||||
break;
|
||||
}
|
||||
let count: u32 = token.parse().unwrap_or(0);
|
||||
data[row][col] = count;
|
||||
if count > max {
|
||||
max = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(data, max)
|
||||
}
|
||||
|
||||
/// Parsed combo: [index, row1, col1, row2, col2, result_keycode]
|
||||
#[derive(Clone)]
|
||||
pub struct ComboEntry {
|
||||
pub index: u8,
|
||||
pub r1: u8,
|
||||
pub c1: u8,
|
||||
pub r2: u8,
|
||||
pub c2: u8,
|
||||
pub result: u16,
|
||||
}
|
||||
|
||||
/// Parse "COMBO0: r3c3+r3c4=29" lines.
|
||||
pub fn parse_combo_lines(lines: &[String]) -> Vec<ComboEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let starts_with_combo = line.starts_with("COMBO");
|
||||
if !starts_with_combo {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find colon: "COMBO0: ..."
|
||||
let colon = match line.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Index between "COMBO" and ":"
|
||||
let index_str = &line[5..colon];
|
||||
let index: u8 = match index_str.parse() {
|
||||
Ok(i) => i,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// After colon: "r3c3+r3c4=29"
|
||||
let rest = line[colon + 1..].trim();
|
||||
|
||||
// Split by "="
|
||||
let eq_parts: Vec<&str> = rest.split('=').collect();
|
||||
let has_two_parts = eq_parts.len() == 2;
|
||||
if !has_two_parts {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split left side by "+"
|
||||
let key_parts: Vec<&str> = eq_parts[0].split('+').collect();
|
||||
let has_two_keys = key_parts.len() == 2;
|
||||
if !has_two_keys {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse "r3c4" format
|
||||
let pos1 = parse_rc(key_parts[0].trim());
|
||||
let pos2 = parse_rc(key_parts[1].trim());
|
||||
|
||||
let (r1, c1) = match pos1 {
|
||||
Some(rc) => rc,
|
||||
None => continue,
|
||||
};
|
||||
let (r2, c2) = match pos2 {
|
||||
Some(rc) => rc,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Parse result keycode (hex)
|
||||
let result_str = eq_parts[1].trim();
|
||||
let result_code: u16 = match u16::from_str_radix(result_str, 16) {
|
||||
Ok(v) => v,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
result.push(ComboEntry {
|
||||
index,
|
||||
r1,
|
||||
c1,
|
||||
r2,
|
||||
c2,
|
||||
result: result_code,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse "r3c4" into (row, col).
|
||||
fn parse_rc(s: &str) -> Option<(u8, u8)> {
|
||||
let lower = s.to_lowercase();
|
||||
|
||||
let r_pos = lower.find('r');
|
||||
let c_pos = lower.find('c');
|
||||
|
||||
let r_idx = match r_pos {
|
||||
Some(i) => i,
|
||||
None => return None,
|
||||
};
|
||||
let c_idx = match c_pos {
|
||||
Some(i) => i,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let row_str = &lower[r_idx + 1..c_idx];
|
||||
let col_str = &lower[c_idx + 1..];
|
||||
|
||||
let row: u8 = match row_str.parse() {
|
||||
Ok(v) => v,
|
||||
_ => return None,
|
||||
};
|
||||
let col: u8 = match col_str.parse() {
|
||||
Ok(v) => v,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some((row, col))
|
||||
}
|
||||
|
||||
/// Parsed leader sequence entry.
|
||||
#[derive(Clone)]
|
||||
pub struct LeaderEntry {
|
||||
pub index: u8,
|
||||
pub sequence: Vec<u8>, // HID keycodes
|
||||
pub result: u8,
|
||||
pub result_mod: u8,
|
||||
}
|
||||
|
||||
/// Parse "LEADER0: 04,->29+00" lines.
|
||||
pub fn parse_leader_lines(lines: &[String]) -> Vec<LeaderEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let starts_with_leader = line.starts_with("LEADER");
|
||||
if !starts_with_leader {
|
||||
continue;
|
||||
}
|
||||
|
||||
let colon = match line.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let index_str = &line[6..colon];
|
||||
let index: u8 = match index_str.parse() {
|
||||
Ok(i) => i,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// After colon: "04,->29+00"
|
||||
let rest = line[colon + 1..].trim();
|
||||
|
||||
// Split by "->"
|
||||
let arrow_parts: Vec<&str> = rest.split("->").collect();
|
||||
let has_two_parts = arrow_parts.len() == 2;
|
||||
if !has_two_parts {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sequence: comma-separated hex keycodes (trailing comma OK)
|
||||
let seq_str = arrow_parts[0].trim().trim_end_matches(',');
|
||||
let sequence: Vec<u8> = seq_str
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let trimmed = s.trim();
|
||||
u8::from_str_radix(trimmed, 16).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Result: "29+00" = keycode + modifier
|
||||
let result_parts: Vec<&str> = arrow_parts[1].trim().split('+').collect();
|
||||
let has_result = result_parts.len() == 2;
|
||||
if !has_result {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result_key = match u8::from_str_radix(result_parts[0].trim(), 16) {
|
||||
Ok(v) => v,
|
||||
_ => continue,
|
||||
};
|
||||
let result_mod = match u8::from_str_radix(result_parts[1].trim(), 16) {
|
||||
Ok(v) => v,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
result.push(LeaderEntry {
|
||||
index,
|
||||
sequence,
|
||||
result: result_key,
|
||||
result_mod,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// A single macro step: keycode + modifier, or delay.
|
||||
#[derive(Clone)]
|
||||
pub struct MacroStep {
|
||||
pub keycode: u8,
|
||||
pub modifier: u8,
|
||||
}
|
||||
|
||||
impl MacroStep {
|
||||
/// Returns true if this step is a delay (keycode 0xFF).
|
||||
pub fn is_delay(&self) -> bool {
|
||||
self.keycode == 0xFF
|
||||
}
|
||||
|
||||
/// Delay in milliseconds (modifier * 10).
|
||||
pub fn delay_ms(&self) -> u32 {
|
||||
self.modifier as u32 * 10
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed macro entry.
|
||||
#[derive(Clone)]
|
||||
pub struct MacroEntry {
|
||||
pub slot: u8,
|
||||
pub name: String,
|
||||
pub steps: Vec<MacroStep>,
|
||||
}
|
||||
|
||||
/// Parse MACROS? text response.
|
||||
/// Lines can be like:
|
||||
/// "MACRO 0: CopyPaste [06:01,FF:0A,19:01]"
|
||||
/// "M0: name=CopyPaste steps=06:01,FF:0A,19:01"
|
||||
/// or just raw text lines
|
||||
pub fn parse_macro_lines(lines: &[String]) -> Vec<MacroEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
// Try format: "MACRO 0: name [steps]" or "M0: ..."
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip empty or header lines
|
||||
let is_empty = trimmed.is_empty();
|
||||
if is_empty {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to find slot number
|
||||
let has_macro_prefix = trimmed.starts_with("MACRO") || trimmed.starts_with("M");
|
||||
if !has_macro_prefix {
|
||||
continue;
|
||||
}
|
||||
|
||||
let colon = match trimmed.find(':') {
|
||||
Some(i) => i,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Extract slot number from prefix
|
||||
let prefix_end = trimmed[..colon].trim();
|
||||
let digits_start = prefix_end
|
||||
.find(|c: char| c.is_ascii_digit())
|
||||
.unwrap_or(prefix_end.len());
|
||||
let slot_str = &prefix_end[digits_start..];
|
||||
let slot: u8 = match slot_str.trim().parse() {
|
||||
Ok(s) => s,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let after_colon = trimmed[colon + 1..].trim();
|
||||
|
||||
// Try to parse name and steps from brackets: "CopyPaste [06:01,FF:0A,19:01]"
|
||||
let bracket_start = after_colon.find('[');
|
||||
let bracket_end = after_colon.find(']');
|
||||
|
||||
let (name, steps_str) = match (bracket_start, bracket_end) {
|
||||
(Some(bs), Some(be)) => {
|
||||
let name_part = after_colon[..bs].trim().to_string();
|
||||
let steps_part = &after_colon[bs + 1..be];
|
||||
(name_part, steps_part.to_string())
|
||||
}
|
||||
_ => {
|
||||
// Try "name=X steps=Y" format
|
||||
let name_eq = after_colon.find("name=");
|
||||
let steps_eq = after_colon.find("steps=");
|
||||
match (name_eq, steps_eq) {
|
||||
(Some(ni), Some(si)) => {
|
||||
let name_start = ni + 5;
|
||||
let name_end = si;
|
||||
let n = after_colon[name_start..name_end].trim().to_string();
|
||||
let s = after_colon[si + 6..].trim().to_string();
|
||||
(n, s)
|
||||
}
|
||||
_ => {
|
||||
// Just use the whole thing as name, no steps
|
||||
(after_colon.to_string(), String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse steps: "06:01,FF:0A,19:01"
|
||||
let mut steps = Vec::new();
|
||||
let has_steps = !steps_str.is_empty();
|
||||
if has_steps {
|
||||
let step_parts = steps_str.split(',');
|
||||
for part in step_parts {
|
||||
let step_trimmed = part.trim();
|
||||
let kv: Vec<&str> = step_trimmed.split(':').collect();
|
||||
let has_two = kv.len() == 2;
|
||||
if !has_two {
|
||||
continue;
|
||||
}
|
||||
let key_byte = u8::from_str_radix(kv[0].trim(), 16).unwrap_or(0);
|
||||
let mod_byte = u8::from_str_radix(kv[1].trim(), 16).unwrap_or(0);
|
||||
steps.push(MacroStep {
|
||||
keycode: key_byte,
|
||||
modifier: mod_byte,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.push(MacroEntry {
|
||||
slot,
|
||||
name,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary payload parsers (protocol v2)
|
||||
// ---------------------------------------------------------------------------
|
||||
// These functions parse the *payload* bytes extracted from a KR response frame.
|
||||
// They produce the same data types as the text parsers above.
|
||||
|
||||
/// Parse TD_LIST (0x51) binary payload.
|
||||
/// Format: [count:u8] then count entries of [idx:u8][a1:u8][a2:u8][a3:u8][a4:u8].
|
||||
/// Returns Vec<[u16; 4]> with 8 slots (same shape as parse_td_lines).
|
||||
pub fn parse_td_binary(payload: &[u8]) -> Vec<[u16; 4]> {
|
||||
let mut result = vec![[0u16; 4]; 8];
|
||||
|
||||
// Need at least 1 byte for count
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let entry_size = 5; // idx(1) + actions(4)
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
// Bounds check: need 5 bytes for this entry
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_bytes = remaining >= entry_size;
|
||||
if !enough_bytes {
|
||||
break;
|
||||
}
|
||||
|
||||
let idx = payload[offset] as usize;
|
||||
let action1 = payload[offset + 1] as u16;
|
||||
let action2 = payload[offset + 2] as u16;
|
||||
let action3 = payload[offset + 3] as u16;
|
||||
let action4 = payload[offset + 4] as u16;
|
||||
|
||||
let valid_index = idx < 8;
|
||||
if valid_index {
|
||||
result[idx] = [action1, action2, action3, action4];
|
||||
}
|
||||
|
||||
offset += entry_size;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse COMBO_LIST (0x61) binary payload.
|
||||
/// Format: [count:u8] then count entries of [idx:u8][r1:u8][c1:u8][r2:u8][c2:u8][result:u8].
|
||||
pub fn parse_combo_binary(payload: &[u8]) -> Vec<ComboEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let entry_size = 6; // idx + r1 + c1 + r2 + c2 + result
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_bytes = remaining >= entry_size;
|
||||
if !enough_bytes {
|
||||
break;
|
||||
}
|
||||
|
||||
let index = payload[offset];
|
||||
let r1 = payload[offset + 1];
|
||||
let c1 = payload[offset + 2];
|
||||
let r2 = payload[offset + 3];
|
||||
let c2 = payload[offset + 4];
|
||||
let result_code = payload[offset + 5] as u16;
|
||||
|
||||
result.push(ComboEntry {
|
||||
index,
|
||||
r1,
|
||||
c1,
|
||||
r2,
|
||||
c2,
|
||||
result: result_code,
|
||||
});
|
||||
|
||||
offset += entry_size;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse LEADER_LIST (0x71) binary payload.
|
||||
/// Format: [count:u8] then per entry: [idx:u8][seq_len:u8][seq: seq_len bytes][result:u8][result_mod:u8].
|
||||
pub fn parse_leader_binary(payload: &[u8]) -> Vec<LeaderEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
// Need at least idx(1) + seq_len(1)
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_for_header = remaining >= 2;
|
||||
if !enough_for_header {
|
||||
break;
|
||||
}
|
||||
|
||||
let index = payload[offset];
|
||||
let seq_len = payload[offset + 1] as usize;
|
||||
offset += 2;
|
||||
|
||||
// Need seq_len bytes for sequence + 2 bytes for result+result_mod
|
||||
let remaining_after_header = payload.len().saturating_sub(offset);
|
||||
let enough_for_body = remaining_after_header >= seq_len + 2;
|
||||
if !enough_for_body {
|
||||
break;
|
||||
}
|
||||
|
||||
let sequence = payload[offset..offset + seq_len].to_vec();
|
||||
offset += seq_len;
|
||||
|
||||
let result_key = payload[offset];
|
||||
let result_mod = payload[offset + 1];
|
||||
offset += 2;
|
||||
|
||||
result.push(LeaderEntry {
|
||||
index,
|
||||
sequence,
|
||||
result: result_key,
|
||||
result_mod,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse KO_LIST (0x92) binary payload.
|
||||
/// Format: [count:u8] then count entries of [idx:u8][trigger_key:u8][trigger_mod:u8][result_key:u8][result_mod:u8].
|
||||
/// Returns Vec<[u8; 4]> = [trigger_key, trigger_mod, result_key, result_mod] (same as parse_ko_lines).
|
||||
pub fn parse_ko_binary(payload: &[u8]) -> Vec<[u8; 4]> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let entry_size = 5; // idx + trigger_key + trigger_mod + result_key + result_mod
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_bytes = remaining >= entry_size;
|
||||
if !enough_bytes {
|
||||
break;
|
||||
}
|
||||
|
||||
// idx is payload[offset], but we skip it (not stored in output)
|
||||
let trigger_key = payload[offset + 1];
|
||||
let trigger_mod = payload[offset + 2];
|
||||
let result_key = payload[offset + 3];
|
||||
let result_mod = payload[offset + 4];
|
||||
|
||||
result.push([trigger_key, trigger_mod, result_key, result_mod]);
|
||||
|
||||
offset += entry_size;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse BT_QUERY (0x80) binary payload.
|
||||
/// Format: [active_slot:u8][initialized:u8][connected:u8][pairing:u8]
|
||||
/// then 3 slot entries: [slot_idx:u8][valid:u8][addr:6 bytes][name_len:u8][name: name_len bytes]
|
||||
/// Returns Vec<String> of text lines compatible with the UI (same shape as legacy text parsing).
|
||||
pub fn parse_bt_binary(payload: &[u8]) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Need at least 4 bytes for the global state header
|
||||
let enough_for_header = payload.len() >= 4;
|
||||
if !enough_for_header {
|
||||
return lines;
|
||||
}
|
||||
|
||||
let active_slot = payload[0];
|
||||
let initialized = payload[1];
|
||||
let connected = payload[2];
|
||||
let pairing = payload[3];
|
||||
|
||||
let status_line = format!(
|
||||
"BT: slot={} init={} conn={} pairing={}",
|
||||
active_slot, initialized, connected, pairing
|
||||
);
|
||||
lines.push(status_line);
|
||||
|
||||
let mut offset = 4;
|
||||
let slot_count = 3;
|
||||
|
||||
for _ in 0..slot_count {
|
||||
// Each slot: slot_idx(1) + valid(1) + addr(6) + name_len(1) = 9 bytes minimum
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_for_slot_header = remaining >= 9;
|
||||
if !enough_for_slot_header {
|
||||
break;
|
||||
}
|
||||
|
||||
let slot_idx = payload[offset];
|
||||
let valid = payload[offset + 1];
|
||||
let addr_bytes = &payload[offset + 2..offset + 8];
|
||||
let name_len = payload[offset + 8] as usize;
|
||||
offset += 9;
|
||||
|
||||
// Read the name string
|
||||
let remaining_for_name = payload.len().saturating_sub(offset);
|
||||
let enough_for_name = remaining_for_name >= name_len;
|
||||
if !enough_for_name {
|
||||
break;
|
||||
}
|
||||
|
||||
let name_bytes = &payload[offset..offset + name_len];
|
||||
let name = String::from_utf8_lossy(name_bytes).to_string();
|
||||
offset += name_len;
|
||||
|
||||
// Format address as "XX:XX:XX:XX:XX:XX"
|
||||
let addr_str = format!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
addr_bytes[0], addr_bytes[1], addr_bytes[2],
|
||||
addr_bytes[3], addr_bytes[4], addr_bytes[5]
|
||||
);
|
||||
|
||||
let slot_line = format!(
|
||||
"BT slot {}: valid={} addr={} name={}",
|
||||
slot_idx, valid, addr_str, name
|
||||
);
|
||||
lines.push(slot_line);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Parse TAMA_QUERY (0xA0) binary payload (22 bytes fixed).
|
||||
/// Format: [enabled:u8][state:u8][hunger:u16 LE][happiness:u16 LE][energy:u16 LE]
|
||||
/// [health:u16 LE][level:u16 LE][xp:u16 LE][total_keys:u32 LE][max_kpm:u32 LE]
|
||||
/// Returns Vec<String> with one summary line.
|
||||
pub fn parse_tama_binary(payload: &[u8]) -> Vec<String> {
|
||||
let expected_size = 22;
|
||||
let enough_bytes = payload.len() >= expected_size;
|
||||
if !enough_bytes {
|
||||
return vec!["TAMA: invalid payload".to_string()];
|
||||
}
|
||||
|
||||
let enabled = payload[0];
|
||||
let _state = payload[1];
|
||||
|
||||
let hunger = u16::from_le_bytes([payload[2], payload[3]]);
|
||||
let happiness = u16::from_le_bytes([payload[4], payload[5]]);
|
||||
let energy = u16::from_le_bytes([payload[6], payload[7]]);
|
||||
let health = u16::from_le_bytes([payload[8], payload[9]]);
|
||||
let level = u16::from_le_bytes([payload[10], payload[11]]);
|
||||
let _xp = u16::from_le_bytes([payload[12], payload[13]]);
|
||||
let total_keys = u32::from_le_bytes([payload[14], payload[15], payload[16], payload[17]]);
|
||||
let _max_kpm = u32::from_le_bytes([payload[18], payload[19], payload[20], payload[21]]);
|
||||
|
||||
let line = format!(
|
||||
"TAMA: Lv{} hunger={} happy={} energy={} health={} keys={} enabled={}",
|
||||
level, hunger, happiness, energy, health, total_keys, enabled
|
||||
);
|
||||
|
||||
vec![line]
|
||||
}
|
||||
|
||||
/// Parse WPM_QUERY (0x93) binary payload (2 bytes fixed).
|
||||
/// Format: [wpm:u16 LE]
|
||||
pub fn parse_wpm_binary(payload: &[u8]) -> String {
|
||||
let enough_bytes = payload.len() >= 2;
|
||||
if !enough_bytes {
|
||||
return "WPM: 0".to_string();
|
||||
}
|
||||
|
||||
let wpm = u16::from_le_bytes([payload[0], payload[1]]);
|
||||
|
||||
format!("WPM: {}", wpm)
|
||||
}
|
||||
|
||||
/// Parse LIST_MACROS (0x30) binary payload.
|
||||
/// Format: [count:u8] then per entry:
|
||||
/// [idx:u8][keycode:u16 LE][name_len:u8][name: name_len bytes]
|
||||
/// [keys_len:u8][keys: keys_len bytes][step_count:u8][{kc:u8,mod:u8}... step_count*2 bytes]
|
||||
pub fn parse_macros_binary(payload: &[u8]) -> Vec<MacroEntry> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let has_count = !payload.is_empty();
|
||||
if !has_count {
|
||||
return result;
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let mut offset = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
// Need at least: idx(1) + keycode(2) + name_len(1) = 4
|
||||
let remaining = payload.len().saturating_sub(offset);
|
||||
let enough_for_prefix = remaining >= 4;
|
||||
if !enough_for_prefix {
|
||||
break;
|
||||
}
|
||||
|
||||
let slot = payload[offset];
|
||||
// keycode is stored but not used in MacroEntry (it's the trigger keycode)
|
||||
let _keycode = u16::from_le_bytes([payload[offset + 1], payload[offset + 2]]);
|
||||
let name_len = payload[offset + 3] as usize;
|
||||
offset += 4;
|
||||
|
||||
// Read name
|
||||
let remaining_for_name = payload.len().saturating_sub(offset);
|
||||
let enough_for_name = remaining_for_name >= name_len;
|
||||
if !enough_for_name {
|
||||
break;
|
||||
}
|
||||
|
||||
let name_bytes = &payload[offset..offset + name_len];
|
||||
let name = String::from_utf8_lossy(name_bytes).to_string();
|
||||
offset += name_len;
|
||||
|
||||
// Read keys_len + keys (raw key bytes, skipped for MacroEntry)
|
||||
let remaining_for_keys_len = payload.len().saturating_sub(offset);
|
||||
let enough_for_keys_len = remaining_for_keys_len >= 1;
|
||||
if !enough_for_keys_len {
|
||||
break;
|
||||
}
|
||||
|
||||
let keys_len = payload[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
let remaining_for_keys = payload.len().saturating_sub(offset);
|
||||
let enough_for_keys = remaining_for_keys >= keys_len;
|
||||
if !enough_for_keys {
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip the raw keys bytes
|
||||
offset += keys_len;
|
||||
|
||||
// Read step_count + steps
|
||||
let remaining_for_step_count = payload.len().saturating_sub(offset);
|
||||
let enough_for_step_count = remaining_for_step_count >= 1;
|
||||
if !enough_for_step_count {
|
||||
break;
|
||||
}
|
||||
|
||||
let step_count = payload[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
let steps_byte_size = step_count * 2;
|
||||
let remaining_for_steps = payload.len().saturating_sub(offset);
|
||||
let enough_for_steps = remaining_for_steps >= steps_byte_size;
|
||||
if !enough_for_steps {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut steps = Vec::with_capacity(step_count);
|
||||
for i in 0..step_count {
|
||||
let step_offset = offset + i * 2;
|
||||
let kc = payload[step_offset];
|
||||
let md = payload[step_offset + 1];
|
||||
steps.push(MacroStep {
|
||||
keycode: kc,
|
||||
modifier: md,
|
||||
});
|
||||
}
|
||||
offset += steps_byte_size;
|
||||
|
||||
result.push(MacroEntry {
|
||||
slot,
|
||||
name,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse KEYSTATS_BIN (0x40) binary payload.
|
||||
/// Format: [rows:u8][cols:u8][counts: rows*cols * u32 LE]
|
||||
/// Returns (heatmap_data, max_value) — same shape as parse_heatmap_lines.
|
||||
pub fn parse_keystats_binary(payload: &[u8]) -> (Vec<Vec<u32>>, u32) {
|
||||
// Need at least 2 bytes for rows and cols
|
||||
let enough_for_header = payload.len() >= 2;
|
||||
if !enough_for_header {
|
||||
return (vec![], 0);
|
||||
}
|
||||
|
||||
let rows = payload[0] as usize;
|
||||
let cols = payload[1] as usize;
|
||||
let total_cells = rows * cols;
|
||||
let data_byte_size = total_cells * 4; // each count is u32 LE
|
||||
|
||||
let remaining = payload.len().saturating_sub(2);
|
||||
let enough_for_data = remaining >= data_byte_size;
|
||||
if !enough_for_data {
|
||||
return (vec![], 0);
|
||||
}
|
||||
|
||||
let mut data: Vec<Vec<u32>> = vec![vec![0u32; cols]; rows];
|
||||
let mut max_value = 0u32;
|
||||
let mut offset = 2;
|
||||
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let count = u32::from_le_bytes([
|
||||
payload[offset],
|
||||
payload[offset + 1],
|
||||
payload[offset + 2],
|
||||
payload[offset + 3],
|
||||
]);
|
||||
data[row][col] = count;
|
||||
|
||||
let is_new_max = count > max_value;
|
||||
if is_new_max {
|
||||
max_value = count;
|
||||
}
|
||||
|
||||
offset += 4;
|
||||
}
|
||||
}
|
||||
|
||||
(data, max_value)
|
||||
}
|
||||
559
original-src/serial/native.rs
Normal file
559
original-src/serial/native.rs
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
use serialport::SerialPort;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::binary_protocol::{self as bp, KrResponse};
|
||||
use crate::parsers::{ROWS, COLS};
|
||||
|
||||
const BAUD_RATE: u32 = 115200;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 300;
|
||||
const QUERY_TIMEOUT_MS: u64 = 800;
|
||||
const BINARY_READ_TIMEOUT_MS: u64 = 1500;
|
||||
const LEGACY_BINARY_SETTLE_MS: u64 = 50;
|
||||
const BINARY_SETTLE_MS: u64 = 30;
|
||||
const JSON_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
pub struct SerialManager {
|
||||
port: Option<Box<dyn SerialPort>>,
|
||||
pub port_name: String,
|
||||
pub connected: bool,
|
||||
pub v2: bool, // true if firmware supports binary protocol v2
|
||||
}
|
||||
|
||||
impl SerialManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
port: None,
|
||||
port_name: String::new(),
|
||||
connected: false,
|
||||
v2: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn list_ports() -> Vec<String> {
|
||||
let available = serialport::available_ports();
|
||||
let ports = available.unwrap_or_default();
|
||||
let port_iter = ports.into_iter();
|
||||
let name_iter = port_iter.map(|p| p.port_name);
|
||||
let names: Vec<String> = name_iter.collect();
|
||||
names
|
||||
}
|
||||
|
||||
pub fn connect(&mut self, port_name: &str) -> Result<(), String> {
|
||||
let builder = serialport::new(port_name, BAUD_RATE);
|
||||
let builder_with_timeout = builder.timeout(Duration::from_millis(CONNECT_TIMEOUT_MS));
|
||||
let open_result = builder_with_timeout.open();
|
||||
let port = open_result.map_err(|e| format!("Failed to open {}: {}", port_name, e))?;
|
||||
|
||||
self.port = Some(port);
|
||||
self.port_name = port_name.to_string();
|
||||
self.connected = true;
|
||||
self.v2 = false;
|
||||
|
||||
// Detect v2: try PING
|
||||
if let Some(p) = self.port.as_mut() {
|
||||
let _ = p.clear(serialport::ClearBuffer::All);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
|
||||
|
||||
let ping_result = self.send_binary(bp::cmd::PING, &[]);
|
||||
if ping_result.is_ok() {
|
||||
self.v2 = true;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn auto_connect(&mut self) -> Result<String, String> {
|
||||
let port_name = Self::find_kase_port()?;
|
||||
self.connect(&port_name)?;
|
||||
Ok(port_name)
|
||||
}
|
||||
|
||||
pub fn find_kase_port() -> Result<String, String> {
|
||||
const TARGET_VID: u16 = 0xCAFE;
|
||||
const TARGET_PID: u16 = 0x4001;
|
||||
|
||||
let available = serialport::available_ports();
|
||||
let ports = available.unwrap_or_default();
|
||||
if ports.is_empty() {
|
||||
return Err("No serial ports found".into());
|
||||
}
|
||||
|
||||
// First pass: check USB VID/PID and product name
|
||||
for port in &ports {
|
||||
let port_type = &port.port_type;
|
||||
match port_type {
|
||||
serialport::SerialPortType::UsbPort(usb) => {
|
||||
let vid_matches = usb.vid == TARGET_VID;
|
||||
let pid_matches = usb.pid == TARGET_PID;
|
||||
if vid_matches && pid_matches {
|
||||
return Ok(port.port_name.clone());
|
||||
}
|
||||
|
||||
match &usb.product {
|
||||
Some(product) => {
|
||||
let is_kase = product.contains("KaSe");
|
||||
let is_kesp = product.contains("KeSp");
|
||||
if is_kase || is_kesp {
|
||||
return Ok(port.port_name.clone());
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass (Linux only): check udevadm info
|
||||
#[cfg(target_os = "linux")]
|
||||
for port in &ports {
|
||||
let udevadm_result = std::process::Command::new("udevadm")
|
||||
.args(["info", "-n", &port.port_name])
|
||||
.output();
|
||||
|
||||
match udevadm_result {
|
||||
Ok(output) => {
|
||||
let stdout_bytes = &output.stdout;
|
||||
let text = String::from_utf8_lossy(stdout_bytes);
|
||||
let has_kase = text.contains("KaSe");
|
||||
let has_kesp = text.contains("KeSp");
|
||||
if has_kase || has_kesp {
|
||||
return Ok(port.port_name.clone());
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let scanned_count = ports.len();
|
||||
Err(format!("No KaSe keyboard found ({} port(s) scanned)", scanned_count))
|
||||
}
|
||||
|
||||
pub fn port_mut(&mut self) -> Option<&mut Box<dyn SerialPort>> {
|
||||
self.port.as_mut()
|
||||
}
|
||||
|
||||
pub fn disconnect(&mut self) {
|
||||
self.port = None;
|
||||
self.port_name.clear();
|
||||
self.connected = false;
|
||||
self.v2 = false;
|
||||
}
|
||||
|
||||
// ==================== LOW-LEVEL: ASCII LEGACY ====================
|
||||
|
||||
pub fn send_command(&mut self, cmd: &str) -> Result<(), String> {
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let data = format!("{}\r\n", cmd);
|
||||
let bytes = data.as_bytes();
|
||||
|
||||
let write_result = port.write_all(bytes);
|
||||
write_result.map_err(|e| format!("Write: {}", e))?;
|
||||
|
||||
let flush_result = port.flush();
|
||||
flush_result.map_err(|e| format!("Flush: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn query_command(&mut self, cmd: &str) -> Result<Vec<String>, String> {
|
||||
self.send_command(cmd)?;
|
||||
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let cloned_port = port.try_clone();
|
||||
let port_clone = cloned_port.map_err(|e| e.to_string())?;
|
||||
let mut reader = BufReader::new(port_clone);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let start = Instant::now();
|
||||
let max_wait = Duration::from_millis(QUERY_TIMEOUT_MS);
|
||||
|
||||
loop {
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed > max_wait {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut line = String::new();
|
||||
let read_result = reader.read_line(&mut line);
|
||||
|
||||
match read_result {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim().to_string();
|
||||
let is_terminal = trimmed == "OK" || trimmed == "ERROR";
|
||||
if is_terminal {
|
||||
break;
|
||||
}
|
||||
let is_not_empty = !trimmed.is_empty();
|
||||
if is_not_empty {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn read_raw(&mut self, timeout_ms: u64) -> Result<Vec<u8>, String> {
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut result = Vec::new();
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
let read_result = port.read(&mut buf);
|
||||
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
let received_bytes = &buf[..n];
|
||||
result.extend_from_slice(received_bytes);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
_ => {
|
||||
let got_something = !result.is_empty();
|
||||
if got_something {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Legacy C> binary protocol
|
||||
fn query_legacy_binary(&mut self, cmd: &str) -> Result<(u8, Vec<u8>), String> {
|
||||
self.send_command(cmd)?;
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
|
||||
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
|
||||
|
||||
// Look for the "C>" header in the raw bytes
|
||||
let mut windows = raw.windows(2);
|
||||
let header_search = windows.position(|w| w == b"C>");
|
||||
let pos = header_search.ok_or("No C> header found")?;
|
||||
|
||||
let min_packet_size = pos + 5;
|
||||
if raw.len() < min_packet_size {
|
||||
return Err("Packet too short".into());
|
||||
}
|
||||
|
||||
let cmd_type = raw[pos + 2];
|
||||
let low_byte = raw[pos + 3] as u16;
|
||||
let high_byte = (raw[pos + 4] as u16) << 8;
|
||||
let data_len = low_byte | high_byte;
|
||||
|
||||
let data_start = pos + 5;
|
||||
let data_end = data_start.checked_add(data_len as usize)
|
||||
.ok_or("Data length overflow")?;
|
||||
|
||||
if raw.len() < data_end {
|
||||
return Err(format!("Incomplete: need {}, got {}", data_end, raw.len()));
|
||||
}
|
||||
|
||||
let payload = raw[data_start..data_end].to_vec();
|
||||
Ok((cmd_type, payload))
|
||||
}
|
||||
|
||||
// ==================== LOW-LEVEL: BINARY V2 ====================
|
||||
|
||||
/// Send a KS frame, read KR response.
|
||||
pub fn send_binary(&mut self, cmd_id: u8, payload: &[u8]) -> Result<KrResponse, String> {
|
||||
let frame = bp::ks_frame(cmd_id, payload);
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
|
||||
let write_result = port.write_all(&frame);
|
||||
write_result.map_err(|e| format!("Write: {}", e))?;
|
||||
|
||||
let flush_result = port.flush();
|
||||
flush_result.map_err(|e| format!("Flush: {}", e))?;
|
||||
|
||||
std::thread::sleep(Duration::from_millis(BINARY_SETTLE_MS));
|
||||
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
|
||||
|
||||
let (resp, _remaining) = bp::parse_kr(&raw)?;
|
||||
let firmware_ok = resp.is_ok();
|
||||
if !firmware_ok {
|
||||
let status = resp.status_name();
|
||||
return Err(format!("Firmware error: {}", status));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// ==================== HIGH-LEVEL: AUTO V2/LEGACY ====================
|
||||
|
||||
pub fn get_firmware_version(&mut self) -> Option<String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let binary_result = self.send_binary(bp::cmd::VERSION, &[]);
|
||||
match binary_result {
|
||||
Ok(resp) => {
|
||||
let raw_bytes = &resp.payload;
|
||||
let lossy_string = String::from_utf8_lossy(raw_bytes);
|
||||
let version = lossy_string.to_string();
|
||||
return Some(version);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback
|
||||
let query_result = self.query_command("VERSION?");
|
||||
let lines = match query_result {
|
||||
Ok(l) => l,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let mut line_iter = lines.into_iter();
|
||||
let first_line = line_iter.next();
|
||||
first_line
|
||||
}
|
||||
|
||||
pub fn get_keymap(&mut self, layer: u8) -> Result<Vec<Vec<u16>>, String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let resp = self.send_binary(bp::cmd::KEYMAP_GET, &[layer])?;
|
||||
let keymap = self.parse_keymap_payload(&resp.payload)?;
|
||||
return Ok(keymap);
|
||||
}
|
||||
|
||||
// Legacy
|
||||
let cmd = format!("KEYMAP{}", layer);
|
||||
let (cmd_type, data) = self.query_legacy_binary(&cmd)?;
|
||||
|
||||
if cmd_type != 1 {
|
||||
return Err(format!("Unexpected cmd type: {}", cmd_type));
|
||||
}
|
||||
if data.len() < 2 {
|
||||
return Err("Data too short".into());
|
||||
}
|
||||
|
||||
// skip 2-byte layer index in legacy
|
||||
let data_without_header = &data[2..];
|
||||
self.parse_keymap_payload(data_without_header)
|
||||
}
|
||||
|
||||
fn parse_keymap_payload(&self, data: &[u8]) -> Result<Vec<Vec<u16>>, String> {
|
||||
// v2: [layer:u8][keycodes...] -- skip first byte
|
||||
// legacy: already stripped
|
||||
let expected_with_layer_byte = 1 + ROWS * COLS * 2;
|
||||
let has_layer_byte = data.len() >= expected_with_layer_byte;
|
||||
let offset = if has_layer_byte { 1 } else { 0 };
|
||||
let kc_data = &data[offset..];
|
||||
|
||||
let needed_bytes = ROWS * COLS * 2;
|
||||
if kc_data.len() < needed_bytes {
|
||||
return Err(format!("Keymap data too short: {} bytes (need {})", kc_data.len(), needed_bytes));
|
||||
}
|
||||
|
||||
let mut keymap = Vec::with_capacity(ROWS);
|
||||
|
||||
for row_index in 0..ROWS {
|
||||
let mut row = Vec::with_capacity(COLS);
|
||||
|
||||
for col_index in 0..COLS {
|
||||
let idx = (row_index * COLS + col_index) * 2;
|
||||
let low_byte = kc_data[idx] as u16;
|
||||
let high_byte = (kc_data[idx + 1] as u16) << 8;
|
||||
let keycode = low_byte | high_byte;
|
||||
row.push(keycode);
|
||||
}
|
||||
|
||||
keymap.push(row);
|
||||
}
|
||||
|
||||
Ok(keymap)
|
||||
}
|
||||
|
||||
pub fn get_layer_names(&mut self) -> Result<Vec<String>, String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let resp = self.send_binary(bp::cmd::LIST_LAYOUTS, &[])?;
|
||||
|
||||
let payload = &resp.payload;
|
||||
if payload.is_empty() {
|
||||
return Err("Empty response".into());
|
||||
}
|
||||
|
||||
let count = payload[0] as usize;
|
||||
let mut names = Vec::with_capacity(count);
|
||||
let mut i = 1;
|
||||
|
||||
for _ in 0..count {
|
||||
let remaining = payload.len();
|
||||
let need_header = i + 2;
|
||||
if need_header > remaining {
|
||||
break;
|
||||
}
|
||||
|
||||
let _layer_index = payload[i];
|
||||
let name_len = payload[i + 1] as usize;
|
||||
i += 2;
|
||||
|
||||
let need_name = i + name_len;
|
||||
if need_name > payload.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let name_bytes = &payload[i..i + name_len];
|
||||
let name_lossy = String::from_utf8_lossy(name_bytes);
|
||||
let name = name_lossy.to_string();
|
||||
names.push(name);
|
||||
i += name_len;
|
||||
}
|
||||
|
||||
let found_names = !names.is_empty();
|
||||
if found_names {
|
||||
return Ok(names);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback: try C> binary protocol
|
||||
let legacy_result = self.query_legacy_binary("LAYOUTS?");
|
||||
match legacy_result {
|
||||
Ok((cmd_type, data)) => {
|
||||
let is_layout_type = cmd_type == 4;
|
||||
let has_data = !data.is_empty();
|
||||
|
||||
if is_layout_type && has_data {
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
let parts = text.split(';');
|
||||
let non_empty = parts.filter(|s| !s.is_empty());
|
||||
let trimmed_names = non_empty.map(|s| {
|
||||
let long_enough = s.len() > 1;
|
||||
if long_enough {
|
||||
s[1..].to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
});
|
||||
let names: Vec<String> = trimmed_names.collect();
|
||||
|
||||
let found_names = !names.is_empty();
|
||||
if found_names {
|
||||
return Ok(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
// Last resort: raw text
|
||||
self.send_command("LAYOUTS?")?;
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS * 2));
|
||||
let raw = self.read_raw(BINARY_READ_TIMEOUT_MS)?;
|
||||
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
let split_by_delimiters = text.split(|c: char| c == ';' || c == '\n');
|
||||
|
||||
let cleaned = split_by_delimiters.map(|s| {
|
||||
let step1 = s.trim();
|
||||
let step2 = step1.trim_matches(|c: char| c.is_control() || c == '"');
|
||||
step2
|
||||
});
|
||||
|
||||
let valid_names = cleaned.filter(|s| {
|
||||
let is_not_empty = !s.is_empty();
|
||||
let is_short_enough = s.len() < 30;
|
||||
let no_header_marker = !s.contains("C>");
|
||||
let not_ok = *s != "OK";
|
||||
is_not_empty && is_short_enough && no_header_marker && not_ok
|
||||
});
|
||||
|
||||
let as_strings = valid_names.map(|s| s.to_string());
|
||||
let names: Vec<String> = as_strings.collect();
|
||||
|
||||
let found_any = !names.is_empty();
|
||||
if found_any {
|
||||
Ok(names)
|
||||
} else {
|
||||
Err("No layer names found".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_layout_json(&mut self) -> Result<String, String> {
|
||||
// Try v2 binary first
|
||||
if self.v2 {
|
||||
let resp = self.send_binary(bp::cmd::GET_LAYOUT_JSON, &[])?;
|
||||
let has_payload = !resp.payload.is_empty();
|
||||
if has_payload {
|
||||
let raw_bytes = &resp.payload;
|
||||
let lossy_string = String::from_utf8_lossy(raw_bytes);
|
||||
let json = lossy_string.to_string();
|
||||
return Ok(json);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: text brace-counting
|
||||
self.send_command("LAYOUT?")?;
|
||||
std::thread::sleep(Duration::from_millis(LEGACY_BINARY_SETTLE_MS));
|
||||
|
||||
let port = self.port.as_mut().ok_or("Not connected")?;
|
||||
let mut result = String::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
let start = Instant::now();
|
||||
let max_wait = Duration::from_secs(JSON_TIMEOUT_SECS);
|
||||
let mut brace_count: i32 = 0;
|
||||
let mut started = false;
|
||||
|
||||
while start.elapsed() < max_wait {
|
||||
let read_result = port.read(&mut buf);
|
||||
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
let received_bytes = &buf[..n];
|
||||
let chunk = String::from_utf8_lossy(received_bytes);
|
||||
|
||||
for ch in chunk.chars() {
|
||||
let is_open_brace = ch == '{';
|
||||
if is_open_brace {
|
||||
started = true;
|
||||
brace_count += 1;
|
||||
}
|
||||
|
||||
if started {
|
||||
result.push(ch);
|
||||
}
|
||||
|
||||
let is_close_brace = ch == '}';
|
||||
if is_close_brace && started {
|
||||
brace_count -= 1;
|
||||
let json_complete = brace_count == 0;
|
||||
if json_complete {
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let got_nothing = result.is_empty();
|
||||
if got_nothing {
|
||||
Err("No JSON".into())
|
||||
} else {
|
||||
Err("Incomplete JSON".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper
|
||||
pub type SharedSerial = Arc<Mutex<SerialManager>>;
|
||||
|
||||
pub fn new_shared() -> SharedSerial {
|
||||
let manager = SerialManager::new();
|
||||
let mutex = Mutex::new(manager);
|
||||
let shared = Arc::new(mutex);
|
||||
shared
|
||||
}
|
||||
335
original-src/stats_analyzer.rs
Normal file
335
original-src/stats_analyzer.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
/// Stats analysis: hand balance, finger load, row usage, top keys, bigrams.
|
||||
/// Transforms raw heatmap data into structured analysis for the stats tab.
|
||||
|
||||
use crate::keycode;
|
||||
use crate::parsers::ROWS;
|
||||
|
||||
/// Which hand a column belongs to.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Hand {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Which finger a column belongs to.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Finger {
|
||||
Pinky,
|
||||
Ring,
|
||||
Middle,
|
||||
Index,
|
||||
Thumb,
|
||||
}
|
||||
|
||||
/// Row names for the 5 rows.
|
||||
const ROW_NAMES: [&str; 5] = ["Number", "Upper", "Home", "Lower", "Thumb"];
|
||||
|
||||
/// Finger names (French).
|
||||
const FINGER_NAMES: [&str; 5] = ["Pinky", "Ring", "Middle", "Index", "Thumb"];
|
||||
|
||||
/// Map column index → (Hand, Finger).
|
||||
/// KaSe layout: cols 0-5 = left hand, cols 6 (gap), cols 7-12 = right hand.
|
||||
fn col_to_hand_finger(col: usize) -> (Hand, Finger) {
|
||||
match col {
|
||||
0 => (Hand::Left, Finger::Pinky),
|
||||
1 => (Hand::Left, Finger::Ring),
|
||||
2 => (Hand::Left, Finger::Middle),
|
||||
3 => (Hand::Left, Finger::Index),
|
||||
4 => (Hand::Left, Finger::Index), // inner column, still index
|
||||
5 => (Hand::Left, Finger::Thumb),
|
||||
6 => (Hand::Left, Finger::Thumb), // center / gap
|
||||
7 => (Hand::Right, Finger::Thumb),
|
||||
8 => (Hand::Right, Finger::Index),
|
||||
9 => (Hand::Right, Finger::Index), // inner column
|
||||
10 => (Hand::Right, Finger::Middle),
|
||||
11 => (Hand::Right, Finger::Ring),
|
||||
12 => (Hand::Right, Finger::Pinky),
|
||||
_ => (Hand::Left, Finger::Pinky),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand balance result.
|
||||
#[allow(dead_code)]
|
||||
pub struct HandBalance {
|
||||
pub left_count: u32,
|
||||
pub right_count: u32,
|
||||
pub total: u32,
|
||||
pub left_pct: f32,
|
||||
pub right_pct: f32,
|
||||
}
|
||||
|
||||
/// Finger load for one finger.
|
||||
#[allow(dead_code)]
|
||||
pub struct FingerLoad {
|
||||
pub name: String,
|
||||
pub hand: Hand,
|
||||
pub count: u32,
|
||||
pub pct: f32,
|
||||
}
|
||||
|
||||
/// Row usage for one row.
|
||||
#[allow(dead_code)]
|
||||
pub struct RowUsage {
|
||||
pub name: String,
|
||||
pub row: usize,
|
||||
pub count: u32,
|
||||
pub pct: f32,
|
||||
}
|
||||
|
||||
/// A key in the top keys ranking.
|
||||
#[allow(dead_code)]
|
||||
pub struct TopKey {
|
||||
pub name: String,
|
||||
pub finger: String,
|
||||
pub count: u32,
|
||||
pub pct: f32,
|
||||
}
|
||||
|
||||
/// Compute hand balance from heatmap data.
|
||||
pub fn hand_balance(heatmap: &[Vec<u32>]) -> HandBalance {
|
||||
let mut left: u32 = 0;
|
||||
let mut right: u32 = 0;
|
||||
|
||||
for row in heatmap {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
let (hand, _) = col_to_hand_finger(c);
|
||||
match hand {
|
||||
Hand::Left => left += count,
|
||||
Hand::Right => right += count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total = left + right;
|
||||
let left_pct = if total > 0 { left as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let right_pct = if total > 0 { right as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
|
||||
HandBalance { left_count: left, right_count: right, total, left_pct, right_pct }
|
||||
}
|
||||
|
||||
/// Compute finger load (10 fingers: 5 left + 5 right).
|
||||
pub fn finger_load(heatmap: &[Vec<u32>]) -> Vec<FingerLoad> {
|
||||
let mut counts = [[0u32; 5]; 2]; // [hand][finger]
|
||||
|
||||
for row in heatmap {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
let (hand, finger) = col_to_hand_finger(c);
|
||||
let hi = if hand == Hand::Left { 0 } else { 1 };
|
||||
let fi = match finger {
|
||||
Finger::Pinky => 0,
|
||||
Finger::Ring => 1,
|
||||
Finger::Middle => 2,
|
||||
Finger::Index => 3,
|
||||
Finger::Thumb => 4,
|
||||
};
|
||||
counts[hi][fi] += count;
|
||||
}
|
||||
}
|
||||
|
||||
let total: u32 = counts[0].iter().sum::<u32>() + counts[1].iter().sum::<u32>();
|
||||
let mut result = Vec::with_capacity(10);
|
||||
|
||||
// Left hand fingers
|
||||
for fi in 0..5 {
|
||||
let count = counts[0][fi];
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let name = format!("{} L", FINGER_NAMES[fi]);
|
||||
result.push(FingerLoad { name, hand: Hand::Left, count, pct });
|
||||
}
|
||||
|
||||
// Right hand fingers
|
||||
for fi in 0..5 {
|
||||
let count = counts[1][fi];
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let name = format!("{} R", FINGER_NAMES[fi]);
|
||||
result.push(FingerLoad { name, hand: Hand::Right, count, pct });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute row usage.
|
||||
pub fn row_usage(heatmap: &[Vec<u32>]) -> Vec<RowUsage> {
|
||||
let mut row_counts = [0u32; ROWS];
|
||||
|
||||
for (r, row) in heatmap.iter().enumerate() {
|
||||
if r >= ROWS { break; }
|
||||
let row_sum: u32 = row.iter().sum();
|
||||
row_counts[r] = row_sum;
|
||||
}
|
||||
|
||||
let total: u32 = row_counts.iter().sum();
|
||||
let mut result = Vec::with_capacity(ROWS);
|
||||
|
||||
for r in 0..ROWS {
|
||||
let count = row_counts[r];
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let name = ROW_NAMES[r].to_string();
|
||||
result.push(RowUsage { name, row: r, count, pct });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute top N keys by press count.
|
||||
pub fn top_keys(heatmap: &[Vec<u32>], keymap: &[Vec<u16>], n: usize) -> Vec<TopKey> {
|
||||
let mut all_keys: Vec<(u32, usize, usize)> = Vec::new(); // (count, row, col)
|
||||
|
||||
for (r, row) in heatmap.iter().enumerate() {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
if count > 0 {
|
||||
all_keys.push((count, r, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_keys.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
all_keys.truncate(n);
|
||||
|
||||
let total: u32 = heatmap.iter().flat_map(|r| r.iter()).sum();
|
||||
|
||||
let mut result = Vec::with_capacity(n);
|
||||
for (count, r, c) in all_keys {
|
||||
let code = keymap.get(r).and_then(|row| row.get(c)).copied().unwrap_or(0);
|
||||
let name = keycode::decode_keycode(code);
|
||||
let (hand, finger) = col_to_hand_finger(c);
|
||||
let hand_str = if hand == Hand::Left { "L" } else { "R" };
|
||||
let finger_str = match finger {
|
||||
Finger::Pinky => "Pinky",
|
||||
Finger::Ring => "Ring",
|
||||
Finger::Middle => "Middle",
|
||||
Finger::Index => "Index",
|
||||
Finger::Thumb => "Thumb",
|
||||
};
|
||||
let finger_label = format!("{} {}", finger_str, hand_str);
|
||||
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
result.push(TopKey { name, finger: finger_label, count, pct });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Find keys that have never been pressed (count = 0, keycode != 0).
|
||||
pub fn dead_keys(heatmap: &[Vec<u32>], keymap: &[Vec<u16>]) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for (r, row) in heatmap.iter().enumerate() {
|
||||
for (c, &count) in row.iter().enumerate() {
|
||||
if count > 0 { continue; }
|
||||
let code = keymap.get(r).and_then(|row| row.get(c)).copied().unwrap_or(0);
|
||||
let is_mapped = code != 0;
|
||||
if is_mapped {
|
||||
let name = keycode::decode_keycode(code);
|
||||
result.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ==================== Bigram analysis ====================
|
||||
|
||||
/// A parsed bigram entry.
|
||||
pub struct BigramEntry {
|
||||
pub from_row: u8,
|
||||
pub from_col: u8,
|
||||
pub to_row: u8,
|
||||
pub to_col: u8,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// Bigram analysis results.
|
||||
#[allow(dead_code)]
|
||||
pub struct BigramAnalysis {
|
||||
pub total: u32,
|
||||
pub alt_hand: u32,
|
||||
pub same_hand: u32,
|
||||
pub sfb: u32,
|
||||
pub alt_hand_pct: f32,
|
||||
pub same_hand_pct: f32,
|
||||
pub sfb_pct: f32,
|
||||
}
|
||||
|
||||
/// Parse bigram text lines from firmware.
|
||||
/// Format: " R2C3 -> R2C4 : 150"
|
||||
pub fn parse_bigram_lines(lines: &[String]) -> Vec<BigramEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
let has_arrow = trimmed.contains("->");
|
||||
if !has_arrow { continue; }
|
||||
|
||||
let parts: Vec<&str> = trimmed.split("->").collect();
|
||||
if parts.len() != 2 { continue; }
|
||||
|
||||
let left = parts[0].trim();
|
||||
let right_and_count = parts[1].trim();
|
||||
|
||||
let right_parts: Vec<&str> = right_and_count.split(':').collect();
|
||||
if right_parts.len() != 2 { continue; }
|
||||
|
||||
let right = right_parts[0].trim();
|
||||
let count_str = right_parts[1].trim();
|
||||
|
||||
let from = parse_rc(left);
|
||||
let to = parse_rc(right);
|
||||
let count: u32 = count_str.parse().unwrap_or(0);
|
||||
|
||||
if let (Some((fr, fc)), Some((tr, tc))) = (from, to) {
|
||||
entries.push(BigramEntry {
|
||||
from_row: fr, from_col: fc,
|
||||
to_row: tr, to_col: tc,
|
||||
count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
/// Parse "R2C3" into (row, col).
|
||||
fn parse_rc(s: &str) -> Option<(u8, u8)> {
|
||||
let s = s.trim();
|
||||
let r_pos = s.find('R')?;
|
||||
let c_pos = s.find('C')?;
|
||||
if c_pos <= r_pos { return None; }
|
||||
|
||||
let row_str = &s[r_pos + 1..c_pos];
|
||||
let col_str = &s[c_pos + 1..];
|
||||
let row: u8 = row_str.parse().ok()?;
|
||||
let col: u8 = col_str.parse().ok()?;
|
||||
Some((row, col))
|
||||
}
|
||||
|
||||
/// Analyze bigram entries for hand alternation and SFB.
|
||||
pub fn analyze_bigrams(entries: &[BigramEntry]) -> BigramAnalysis {
|
||||
let mut alt_hand: u32 = 0;
|
||||
let mut same_hand: u32 = 0;
|
||||
let mut sfb: u32 = 0;
|
||||
let mut total: u32 = 0;
|
||||
|
||||
for entry in entries {
|
||||
let (hand_from, finger_from) = col_to_hand_finger(entry.from_col as usize);
|
||||
let (hand_to, finger_to) = col_to_hand_finger(entry.to_col as usize);
|
||||
|
||||
total += entry.count;
|
||||
|
||||
if hand_from != hand_to {
|
||||
alt_hand += entry.count;
|
||||
} else {
|
||||
same_hand += entry.count;
|
||||
if finger_from == finger_to {
|
||||
sfb += entry.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let alt_hand_pct = if total > 0 { alt_hand as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let same_hand_pct = if total > 0 { same_hand as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
let sfb_pct = if total > 0 { sfb as f32 / total as f32 * 100.0 } else { 0.0 };
|
||||
|
||||
BigramAnalysis {
|
||||
total, alt_hand, same_hand, sfb,
|
||||
alt_hand_pct, same_hand_pct, sfb_pct,
|
||||
}
|
||||
}
|
||||
263
original-src/ui_background.rs
Normal file
263
original-src/ui_background.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
use super::BgResult;
|
||||
|
||||
/// Map a query tag to its binary command ID.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::binary_protocol as bp;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn tag_to_binary_cmd(tag: &str) -> Option<u8> {
|
||||
match tag {
|
||||
"td" => Some(bp::cmd::TD_LIST),
|
||||
"combo" => Some(bp::cmd::COMBO_LIST),
|
||||
"leader" => Some(bp::cmd::LEADER_LIST),
|
||||
"ko" => Some(bp::cmd::KO_LIST),
|
||||
"bt" => Some(bp::cmd::BT_QUERY),
|
||||
"tama" => Some(bp::cmd::TAMA_QUERY),
|
||||
"wpm" => Some(bp::cmd::WPM_QUERY),
|
||||
"macros" => Some(bp::cmd::LIST_MACROS),
|
||||
"keystats" => Some(bp::cmd::KEYSTATS_BIN),
|
||||
"features" => Some(bp::cmd::FEATURES),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl super::KaSeApp {
|
||||
// ---- Background helpers ----
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn bg_query(&mut self, tag: &str, cmd: &str) {
|
||||
self.busy = true;
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let tag = tag.to_string();
|
||||
let cmd = cmd.to_string();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let lines = ser.query_command(&cmd).unwrap_or_default();
|
||||
let _ = tx.send(BgResult::TextLines(tag, lines));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn bg_query(&mut self, tag: &str, cmd: &str) {
|
||||
if self.web_busy.get() { return; }
|
||||
self.busy = true;
|
||||
self.web_busy.set(true);
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
let tag = tag.to_string();
|
||||
let cmd = cmd.to_string();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let v2 = serial.borrow().v2;
|
||||
let handles = serial.borrow().io_handles();
|
||||
if let Ok((reader, writer)) = handles {
|
||||
if let Some(cmd_id) = v2.then(|| tag_to_binary_cmd(&tag)).flatten() {
|
||||
match crate::serial::send_binary(&reader, &writer, cmd_id, &[]).await {
|
||||
Ok(resp) => {
|
||||
let _ = tx.send(BgResult::BinaryPayload(tag, resp.payload));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
} else {
|
||||
let result = crate::serial::query_command(&reader, &writer, &cmd).await;
|
||||
match result {
|
||||
Ok(lines) => {
|
||||
let _ = tx.send(BgResult::TextLines(tag, lines));
|
||||
}
|
||||
Err(e) => {
|
||||
if e == "timeout_refresh" {
|
||||
serial.borrow_mut().refresh_reader();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
web_busy.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Run multiple queries sequentially (avoids mutex contention).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn bg_query_batch(&mut self, queries: &[(&str, &str)]) {
|
||||
self.busy = true;
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
|
||||
let owned_queries: Vec<(String, String)> = queries
|
||||
.iter()
|
||||
.map(|(t, c)| (t.to_string(), c.to_string()))
|
||||
.collect();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
|
||||
for (tag, cmd) in owned_queries {
|
||||
let lines = ser.query_command(&cmd).unwrap_or_default();
|
||||
let _ = tx.send(BgResult::TextLines(tag, lines));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn bg_query_batch(&mut self, queries: &[(&str, &str)]) {
|
||||
if self.web_busy.get() { return; }
|
||||
self.busy = true;
|
||||
self.web_busy.set(true);
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
|
||||
let owned: Vec<(String, String)> = queries
|
||||
.iter()
|
||||
.map(|(t, c)| (t.to_string(), c.to_string()))
|
||||
.collect();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let v2 = serial.borrow().v2;
|
||||
|
||||
for (tag, text_cmd) in &owned {
|
||||
let handles = serial.borrow().io_handles();
|
||||
let (reader, writer) = match handles {
|
||||
Ok(h) => h,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if let Some(cmd_id) = v2.then(|| tag_to_binary_cmd(tag)).flatten() {
|
||||
match crate::serial::send_binary(&reader, &writer, cmd_id, &[]).await {
|
||||
Ok(resp) => {
|
||||
let _ = tx.send(BgResult::BinaryPayload(tag.clone(), resp.payload));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
} else {
|
||||
match crate::serial::query_command(&reader, &writer, text_cmd).await {
|
||||
Ok(lines) => {
|
||||
let _ = tx.send(BgResult::TextLines(tag.clone(), lines));
|
||||
}
|
||||
Err(e) => {
|
||||
if e == "timeout_refresh" {
|
||||
serial.borrow_mut().refresh_reader();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
web_busy.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn bg_send(&self, cmd: &str) {
|
||||
let serial = self.serial.clone();
|
||||
let cmd = cmd.to_string();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _ = ser.send_command(&cmd);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn bg_send(&self, cmd: &str) {
|
||||
let serial = self.serial.clone();
|
||||
let cmd = cmd.to_string();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let handles = serial.borrow().io_handles();
|
||||
if let Ok((_reader, writer)) = handles {
|
||||
let _ = crate::serial::send_command(&writer, &cmd).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Data helpers ----
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_keymap(&mut self) {
|
||||
self.busy = true;
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let layer = self.current_layer as u8;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
|
||||
match ser.get_keymap(layer) {
|
||||
Ok(km) => { let _ = tx.send(BgResult::Keymap(km)); }
|
||||
Err(e) => { let _ = tx.send(BgResult::Error(e)); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn load_keymap(&mut self) {
|
||||
if self.web_busy.get() { return; }
|
||||
self.busy = true;
|
||||
self.web_busy.set(true);
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
let layer = self.current_layer as u8;
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let ser = serial.borrow();
|
||||
let v2 = ser.v2;
|
||||
let handles = ser.io_handles();
|
||||
drop(ser);
|
||||
match handles {
|
||||
Ok((reader, writer)) => {
|
||||
match crate::serial::get_keymap(&reader, &writer, layer, v2).await {
|
||||
Ok(km) => { let _ = tx.send(BgResult::Keymap(km)); }
|
||||
Err(e) => { let _ = tx.send(BgResult::Error(e)); }
|
||||
}
|
||||
}
|
||||
Err(e) => { let _ = tx.send(BgResult::Error(e)); }
|
||||
}
|
||||
web_busy.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_layer_names(&self) {
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
|
||||
match ser.get_layer_names() {
|
||||
Ok(names) if !names.is_empty() => {
|
||||
let _ = tx.send(BgResult::LayerNames(names));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn load_layer_names(&self) {
|
||||
if self.web_busy.get() { return; }
|
||||
self.web_busy.set(true);
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let ser = serial.borrow();
|
||||
let v2 = ser.v2;
|
||||
let handles = ser.io_handles();
|
||||
drop(ser);
|
||||
if let Ok((reader, writer)) = handles {
|
||||
match crate::serial::get_layer_names(&reader, &writer, v2).await {
|
||||
Ok(names) if !names.is_empty() => {
|
||||
let _ = tx.send(BgResult::LayerNames(names));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
web_busy.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
139
original-src/ui_connection.rs
Normal file
139
original-src/ui_connection.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use super::BgResult;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use eframe::egui;
|
||||
|
||||
impl super::KaSeApp {
|
||||
// ---- Connection helpers ----
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn is_connected(&self) -> bool {
|
||||
// Utilise le cache — PAS de serial.lock() ici !
|
||||
// Sinon le thread UI bloque quand un thread background
|
||||
// (OTA, query batch) tient le lock.
|
||||
self.connected_cache
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn is_connected(&self) -> bool {
|
||||
let ser = self.serial.borrow();
|
||||
ser.connected
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn connect(&mut self) {
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
self.busy = true;
|
||||
self.status_msg = "Scanning ports...".into();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = match serial.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
|
||||
match ser.auto_connect() {
|
||||
Ok(port_name) => {
|
||||
let fw = ser.get_firmware_version().unwrap_or_default();
|
||||
let names = ser.get_layer_names().unwrap_or_default();
|
||||
let km = ser.get_keymap(0).unwrap_or_default();
|
||||
let _ = tx.send(BgResult::Connected(port_name, fw, names, km));
|
||||
|
||||
// Try to fetch physical layout from firmware
|
||||
let layout = ser.get_layout_json()
|
||||
.ok()
|
||||
.and_then(|json| crate::layout::parse_json(&json).ok());
|
||||
if let Some(keys) = layout {
|
||||
let _ = tx.send(BgResult::LayoutJson(keys));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(BgResult::ConnectError(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn connect_web(&mut self, ctx: &egui::Context) {
|
||||
self.busy = true;
|
||||
self.web_busy.set(true);
|
||||
self.status_msg = "Selecting port...".into();
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
let ctx = ctx.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Step 1: async port selection (no borrow held)
|
||||
let conn = match crate::serial::request_port().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = tx.send(BgResult::ConnectError(e));
|
||||
web_busy.set(false);
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: extract handles before moving conn
|
||||
let reader = conn.reader.clone();
|
||||
let writer = conn.writer.clone();
|
||||
let v2 = conn.v2;
|
||||
|
||||
// Step 3: store connection
|
||||
serial.borrow_mut().apply_connection(conn);
|
||||
|
||||
// Step 4: async queries (no borrow held)
|
||||
let fw = crate::serial::get_firmware_version(&reader, &writer, v2)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let names = crate::serial::get_layer_names(&reader, &writer, v2)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let km = crate::serial::get_keymap(&reader, &writer, 0, v2)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let _ = tx.send(BgResult::Connected("WebSerial".into(), fw, names, km));
|
||||
|
||||
// Try to fetch physical layout from firmware
|
||||
let layout_json = crate::serial::get_layout_json(&reader, &writer, v2).await;
|
||||
if let Ok(json) = layout_json {
|
||||
if let Ok(keys) = crate::layout::parse_json(&json) {
|
||||
let _ = tx.send(BgResult::LayoutJson(keys));
|
||||
}
|
||||
}
|
||||
|
||||
web_busy.set(false);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn disconnect(&mut self) {
|
||||
let mut guard = match self.serial.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard.disconnect();
|
||||
drop(guard);
|
||||
|
||||
self.connected_cache = false;
|
||||
self.firmware_version.clear();
|
||||
self.keymap.clear();
|
||||
self.layer_names = vec!["Layer 0".into()];
|
||||
self.status_msg = "Disconnected".into();
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn disconnect(&mut self) {
|
||||
self.serial.borrow_mut().disconnect();
|
||||
|
||||
self.firmware_version.clear();
|
||||
self.keymap.clear();
|
||||
self.layer_names = vec!["Layer 0".into()];
|
||||
self.status_msg = "Disconnected".into();
|
||||
}
|
||||
}
|
||||
218
original-src/ui_helpers.rs
Normal file
218
original-src/ui_helpers.rs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
use super::{BgResult, Instant};
|
||||
|
||||
impl super::KaSeApp {
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn notify(&mut self, msg: &str) {
|
||||
let timestamp = Instant::now();
|
||||
let entry = (msg.to_string(), timestamp);
|
||||
self.notifications.push(entry);
|
||||
}
|
||||
|
||||
pub(super) fn get_heatmap_intensity(&self, row: usize, col: usize) -> f32 {
|
||||
if !self.heatmap_on || self.heatmap_max == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let row_data = self.heatmap_data.get(row);
|
||||
let cell_option = row_data.and_then(|r| r.get(col));
|
||||
let count = cell_option.copied().unwrap_or(0);
|
||||
|
||||
let count_float = count as f32;
|
||||
let max_float = self.heatmap_max as f32;
|
||||
let intensity = count_float / max_float;
|
||||
intensity
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_heatmap(&mut self) {
|
||||
self.busy = true;
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let lines = ser.query_command("KEYSTATS?").unwrap_or_default();
|
||||
let (data, max) = crate::parsers::parse_heatmap_lines(&lines);
|
||||
let _ = tx.send(BgResult::HeatmapData(data, max));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn load_heatmap(&mut self) {
|
||||
if self.web_busy.get() { return; }
|
||||
self.busy = true;
|
||||
self.web_busy.set(true);
|
||||
let serial = self.serial.clone();
|
||||
let tx = self.bg_tx.clone();
|
||||
let web_busy = self.web_busy.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let handles = serial.borrow().io_handles();
|
||||
if let Ok((reader, writer)) = handles {
|
||||
let lines = crate::serial::query_command(&reader, &writer, "KEYSTATS?")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let (data, max) = crate::parsers::parse_heatmap_lines(&lines);
|
||||
let _ = tx.send(BgResult::HeatmapData(data, max));
|
||||
}
|
||||
web_busy.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// Apply a key selection from the key selector.
|
||||
/// row == 951: new KO result key
|
||||
/// row == 950: new KO trigger key
|
||||
/// row 900-949: KO edit (900 + idx*10 + 0=trig, +1=result)
|
||||
/// row >= 800: new leader result
|
||||
/// row >= 700: new leader sequence key (append)
|
||||
/// row >= 600: leader result edit (row-600 = leader index)
|
||||
/// row >= 500: leader sequence key edit (row = 500 + idx*10 + seq_pos)
|
||||
/// row >= 400: new combo result mode
|
||||
/// row >= 300: combo result edit (row-300 = combo index)
|
||||
/// row >= 200: macro step mode (add key as step)
|
||||
/// row >= 100: TD mode (row-100 = td index, col = action slot)
|
||||
/// row < 100: keymap mode
|
||||
pub(super) fn apply_key_selection(&mut self, row: usize, col: usize, code: u16) {
|
||||
if row == 951 {
|
||||
// New KO result key
|
||||
self.ko_new_res_key = code as u8;
|
||||
self.ko_new_res_set = true;
|
||||
self.status_msg = format!("KO result = {}", crate::keycode::hid_key_name(code as u8));
|
||||
return;
|
||||
}
|
||||
|
||||
if row == 950 {
|
||||
// New KO trigger key
|
||||
self.ko_new_trig_key = code as u8;
|
||||
self.ko_new_trig_set = true;
|
||||
self.status_msg = format!("KO trigger = {}", crate::keycode::hid_key_name(code as u8));
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 900 {
|
||||
// KO edit: row = 900 + idx*10 + field (0=trig, 1=result)
|
||||
let offset = row - 900;
|
||||
let ko_idx = offset / 10;
|
||||
let field = offset % 10;
|
||||
let idx_valid = ko_idx < self.ko_data.len();
|
||||
if idx_valid {
|
||||
if field == 0 {
|
||||
self.ko_data[ko_idx][0] = code as u8;
|
||||
self.status_msg = format!("KO #{} trigger = 0x{:02X}", ko_idx, code);
|
||||
} else {
|
||||
self.ko_data[ko_idx][2] = code as u8;
|
||||
self.status_msg = format!("KO #{} result = 0x{:02X}", ko_idx, code);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 800 {
|
||||
// New leader result
|
||||
self.leader_new_result = code as u8;
|
||||
self.leader_new_result_set = true;
|
||||
self.status_msg = format!("Leader result = {}", crate::keycode::hid_key_name(code as u8));
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 700 {
|
||||
// New leader sequence key (append)
|
||||
let seq_not_full = self.leader_new_seq.len() < 4;
|
||||
if seq_not_full {
|
||||
self.leader_new_seq.push(code as u8);
|
||||
let key_name = crate::keycode::hid_key_name(code as u8);
|
||||
self.status_msg = format!("Leader seq + {}", key_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 600 {
|
||||
// Leader result edit (existing)
|
||||
let leader_idx = row - 600;
|
||||
let idx_valid = leader_idx < self.leader_data.len();
|
||||
if idx_valid {
|
||||
self.leader_data[leader_idx].result = code as u8;
|
||||
self.status_msg = format!("Leader #{} result = 0x{:02X}", leader_idx, code);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 500 {
|
||||
// Leader sequence key edit (existing)
|
||||
// row = 500 + leader_idx*10 + seq_pos
|
||||
let offset = row - 500;
|
||||
let leader_idx = offset / 10;
|
||||
let seq_pos = offset % 10;
|
||||
let idx_valid = leader_idx < self.leader_data.len();
|
||||
if idx_valid {
|
||||
let seq_valid = seq_pos < self.leader_data[leader_idx].sequence.len();
|
||||
if seq_valid {
|
||||
self.leader_data[leader_idx].sequence[seq_pos] = code as u8;
|
||||
self.status_msg = format!("Leader #{} key {} = 0x{:02X}", leader_idx, seq_pos, code);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 400 {
|
||||
// New combo result mode
|
||||
self.combo_new_result = code;
|
||||
self.status_msg = format!("New combo result = 0x{:04X}", code);
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 300 {
|
||||
// Combo result edit mode
|
||||
let combo_idx = row - 300;
|
||||
let idx_valid = combo_idx < self.combo_data.len();
|
||||
if idx_valid {
|
||||
self.combo_data[combo_idx].result = code;
|
||||
self.status_msg = format!("Combo #{} result = 0x{:04X}", combo_idx, code);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 200 {
|
||||
// Macro step mode
|
||||
self.apply_macro_step(code);
|
||||
return;
|
||||
}
|
||||
|
||||
if row >= 100 {
|
||||
// TD mode
|
||||
let td_idx = row - 100;
|
||||
let idx_valid = td_idx < self.td_data.len();
|
||||
let col_valid = col < 4;
|
||||
|
||||
if idx_valid && col_valid {
|
||||
self.td_data[td_idx][col] = code;
|
||||
self.status_msg = format!("TD {} action {} = 0x{:04X}", td_idx, col, code);
|
||||
}
|
||||
} else {
|
||||
// Keymap mode - validate bounds BEFORE sending
|
||||
let row_valid = row < self.keymap.len();
|
||||
let col_valid = row_valid && col < self.keymap[row].len();
|
||||
|
||||
if col_valid {
|
||||
let layer = self.current_layer as u8;
|
||||
let row_byte = row as u8;
|
||||
let col_byte = col as u8;
|
||||
let cmd = crate::protocol::cmd_set_key(layer, row_byte, col_byte, code);
|
||||
self.bg_send(&cmd);
|
||||
|
||||
self.keymap[row][col] = code;
|
||||
self.status_msg = format!("[{},{}] = 0x{:04X}", row, col, code);
|
||||
} else {
|
||||
self.status_msg = format!("Invalid key position [{},{}]", row, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_key(&self, row: usize, col: usize) -> u16 {
|
||||
let row_data = self.keymap.get(row);
|
||||
let cell_option = row_data.and_then(|r| r.get(col));
|
||||
let value = cell_option.copied();
|
||||
value.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
277
original-src/ui_mod.rs
Normal file
277
original-src/ui_mod.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
mod background;
|
||||
mod connection;
|
||||
mod geometry;
|
||||
mod helpers;
|
||||
mod key_selector;
|
||||
mod render;
|
||||
mod tab_advanced;
|
||||
mod tab_keymap;
|
||||
mod tab_macros;
|
||||
mod tab_settings;
|
||||
mod tab_stats;
|
||||
mod update;
|
||||
|
||||
use std::sync::mpsc;
|
||||
use crate::serial::{self, SharedSerial};
|
||||
use crate::layout::{self, KeycapPos};
|
||||
use crate::layout_remap::KeyboardLayout;
|
||||
|
||||
// Instant doesn't exist in WASM - use a wrapper
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type Instant = std::time::Instant;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(Clone, Copy)]
|
||||
struct Instant(f64);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl Instant {
|
||||
fn now() -> Self {
|
||||
let window = web_sys::window().unwrap();
|
||||
let performance = window.performance().unwrap();
|
||||
Instant(performance.now())
|
||||
}
|
||||
fn elapsed(&self) -> std::time::Duration {
|
||||
let now = Self::now();
|
||||
let ms = now.0 - self.0;
|
||||
std::time::Duration::from_millis(ms as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Results from background serial operations.
|
||||
pub(super) enum BgResult {
|
||||
Connected(String, String, Vec<String>, Vec<Vec<u16>>),
|
||||
ConnectError(String),
|
||||
Keymap(Vec<Vec<u16>>),
|
||||
LayerNames(Vec<String>),
|
||||
TextLines(String, Vec<String>),
|
||||
#[allow(dead_code)] // constructed only in WASM builds
|
||||
BinaryPayload(String, Vec<u8>), // tag, raw KR payload
|
||||
LayoutJson(Vec<crate::layout::KeycapPos>), // physical key positions from firmware
|
||||
OtaProgress(f32, String), // progress 0-1, status message
|
||||
HeatmapData(Vec<Vec<u32>>, u32), // counts, max
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(super) enum Tab {
|
||||
Keymap,
|
||||
Advanced,
|
||||
Macros,
|
||||
Stats,
|
||||
Settings,
|
||||
}
|
||||
|
||||
pub struct KaSeApp {
|
||||
pub(super) serial: SharedSerial,
|
||||
pub(super) bg_tx: mpsc::Sender<BgResult>,
|
||||
pub(super) bg_rx: mpsc::Receiver<BgResult>,
|
||||
pub(super) busy: bool,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) web_busy: std::rc::Rc<std::cell::Cell<bool>>,
|
||||
pub(super) tab: Tab,
|
||||
|
||||
// Connection
|
||||
/// Cached connection status — avoids serial.lock() in views.
|
||||
/// Updated in poll_bg() when Connected/ConnectError is received.
|
||||
pub(super) connected_cache: bool,
|
||||
pub(super) firmware_version: String,
|
||||
|
||||
// Keymap
|
||||
pub(super) current_layer: usize,
|
||||
pub(super) layer_names: Vec<String>,
|
||||
pub(super) keymap: Vec<Vec<u16>>, // rows x cols
|
||||
pub(super) key_layout: Vec<KeycapPos>,
|
||||
pub(super) editing_key: Option<(usize, usize)>, // (row, col) being edited
|
||||
|
||||
// Keymap editing
|
||||
pub(super) layer_rename: String,
|
||||
|
||||
// Advanced
|
||||
pub(super) td_lines: Vec<String>,
|
||||
pub(super) td_data: Vec<[u16; 4]>, // parsed tap dance slots
|
||||
pub(super) combo_lines: Vec<String>,
|
||||
pub(super) combo_data: Vec<crate::parsers::ComboEntry>,
|
||||
/// None = pas de picking en cours. Some((combo_idx, slot)) = on attend un clic clavier.
|
||||
/// combo_idx: usize::MAX = nouveau combo. slot: 0 = key1, 1 = key2.
|
||||
pub(super) combo_picking: Option<(usize, u8)>,
|
||||
pub(super) combo_new_r1: u8,
|
||||
pub(super) combo_new_c1: u8,
|
||||
pub(super) combo_new_r2: u8,
|
||||
pub(super) combo_new_c2: u8,
|
||||
pub(super) combo_new_result: u16,
|
||||
pub(super) combo_new_key1_set: bool,
|
||||
pub(super) combo_new_key2_set: bool,
|
||||
pub(super) leader_lines: Vec<String>,
|
||||
pub(super) leader_data: Vec<crate::parsers::LeaderEntry>,
|
||||
// Leader editing: new sequence being built
|
||||
pub(super) leader_new_seq: Vec<u8>, // HID keycodes for the sequence
|
||||
pub(super) leader_new_result: u8,
|
||||
pub(super) leader_new_mod: u8,
|
||||
pub(super) leader_new_result_set: bool,
|
||||
pub(super) ko_lines: Vec<String>,
|
||||
pub(super) ko_data: Vec<[u8; 4]>, // parsed: [trigger, trig_mod, result, res_mod]
|
||||
pub(super) ko_new_trig_key: u8,
|
||||
pub(super) ko_new_trig_mod: u8,
|
||||
pub(super) ko_new_res_key: u8,
|
||||
pub(super) ko_new_res_mod: u8,
|
||||
pub(super) ko_new_trig_set: bool,
|
||||
pub(super) ko_new_res_set: bool,
|
||||
pub(super) bt_lines: Vec<String>,
|
||||
pub(super) tama_lines: Vec<String>,
|
||||
pub(super) wpm_text: String,
|
||||
pub(super) autoshift_status: String,
|
||||
pub(super) tri_l1: String,
|
||||
pub(super) tri_l2: String,
|
||||
pub(super) tri_l3: String,
|
||||
|
||||
// Macros
|
||||
pub(super) macro_lines: Vec<String>,
|
||||
pub(super) macro_data: Vec<crate::parsers::MacroEntry>,
|
||||
pub(super) macro_slot: String,
|
||||
pub(super) macro_name: String,
|
||||
pub(super) macro_steps: String,
|
||||
|
||||
// Stats / Heatmap
|
||||
pub(super) keystats_lines: Vec<String>,
|
||||
pub(super) bigrams_lines: Vec<String>,
|
||||
pub(super) heatmap_data: Vec<Vec<u32>>, // rows x cols press counts
|
||||
pub(super) heatmap_max: u32,
|
||||
pub(super) heatmap_on: bool,
|
||||
pub(super) heatmap_selected: Option<(usize, usize)>, // selected key for bigram view
|
||||
pub(super) stats_dirty: bool,
|
||||
|
||||
// Key selector
|
||||
pub(super) key_search: String,
|
||||
pub(super) mt_mod: u8,
|
||||
pub(super) mt_key: u8,
|
||||
pub(super) lt_layer: u8,
|
||||
pub(super) lt_key: u8,
|
||||
pub(super) hex_input: String,
|
||||
|
||||
// Settings
|
||||
pub(super) keyboard_layout: crate::layout_remap::KeyboardLayout,
|
||||
|
||||
// OTA
|
||||
pub(super) ota_path: String,
|
||||
pub(super) ota_status: String,
|
||||
pub(super) ota_firmware_data: Vec<u8>,
|
||||
pub(super) ota_progress: f32,
|
||||
pub(super) ota_releases: Vec<(String, String)>, // (tag_name, asset_url)
|
||||
pub(super) ota_selected_release: usize,
|
||||
|
||||
// Prog port flasher (native only)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) prog_port: String,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) prog_path: String,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) prog_ports_list: Vec<String>,
|
||||
|
||||
// Notifications
|
||||
pub(super) notifications: Vec<(String, Instant)>,
|
||||
|
||||
// Status
|
||||
pub(super) status_msg: String,
|
||||
pub(super) last_reconnect_poll: Instant,
|
||||
pub(super) last_port_check: Instant,
|
||||
pub(super) last_wpm_poll: Instant,
|
||||
pub(super) last_stats_refresh: Instant,
|
||||
}
|
||||
|
||||
impl KaSeApp {
|
||||
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||
let (bg_tx, bg_rx) = mpsc::channel();
|
||||
|
||||
let saved_settings = crate::settings::load();
|
||||
let keyboard_layout = KeyboardLayout::from_name(&saved_settings.keyboard_layout);
|
||||
|
||||
Self {
|
||||
serial: serial::new_shared(),
|
||||
bg_tx,
|
||||
bg_rx,
|
||||
busy: false,
|
||||
connected_cache: false,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_busy: std::rc::Rc::new(std::cell::Cell::new(false)),
|
||||
tab: Tab::Keymap,
|
||||
firmware_version: String::new(),
|
||||
current_layer: 0,
|
||||
layer_names: vec!["Layer 0".into()],
|
||||
keymap: Vec::new(),
|
||||
key_layout: layout::default_layout(),
|
||||
editing_key: None,
|
||||
layer_rename: String::new(),
|
||||
td_lines: Vec::new(),
|
||||
td_data: Vec::new(),
|
||||
combo_lines: Vec::new(),
|
||||
combo_data: Vec::new(),
|
||||
combo_picking: None,
|
||||
combo_new_r1: 0,
|
||||
combo_new_c1: 0,
|
||||
combo_new_r2: 0,
|
||||
combo_new_c2: 0,
|
||||
combo_new_result: 0,
|
||||
combo_new_key1_set: false,
|
||||
combo_new_key2_set: false,
|
||||
leader_new_seq: Vec::new(),
|
||||
leader_new_result: 0,
|
||||
leader_new_mod: 0,
|
||||
leader_new_result_set: false,
|
||||
leader_lines: Vec::new(),
|
||||
leader_data: Vec::new(),
|
||||
ko_lines: Vec::new(),
|
||||
ko_data: Vec::new(),
|
||||
ko_new_trig_key: 0,
|
||||
ko_new_trig_mod: 0,
|
||||
ko_new_res_key: 0,
|
||||
ko_new_res_mod: 0,
|
||||
ko_new_trig_set: false,
|
||||
ko_new_res_set: false,
|
||||
bt_lines: Vec::new(),
|
||||
macro_lines: Vec::new(),
|
||||
macro_data: Vec::new(),
|
||||
tama_lines: Vec::new(),
|
||||
wpm_text: String::new(),
|
||||
autoshift_status: String::new(),
|
||||
tri_l1: "1".into(),
|
||||
tri_l2: "2".into(),
|
||||
tri_l3: "3".into(),
|
||||
macro_slot: "0".into(),
|
||||
macro_name: String::new(),
|
||||
macro_steps: String::new(),
|
||||
keystats_lines: Vec::new(),
|
||||
bigrams_lines: Vec::new(),
|
||||
heatmap_data: Vec::new(),
|
||||
heatmap_max: 0,
|
||||
heatmap_on: false,
|
||||
heatmap_selected: None,
|
||||
stats_dirty: true,
|
||||
key_search: String::new(),
|
||||
mt_mod: 0x02, // HID Left Shift
|
||||
mt_key: 0x04, // HID 'A'
|
||||
lt_layer: 1,
|
||||
lt_key: 0x2C, // HID Space
|
||||
hex_input: String::new(),
|
||||
keyboard_layout,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
prog_port: String::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
prog_path: String::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
prog_ports_list: Vec::new(),
|
||||
notifications: Vec::new(),
|
||||
ota_path: String::new(),
|
||||
ota_status: String::new(),
|
||||
ota_firmware_data: Vec::new(),
|
||||
ota_progress: 0.0,
|
||||
ota_releases: Vec::new(),
|
||||
ota_selected_release: 0,
|
||||
status_msg: "Searching KeSp...".into(),
|
||||
last_reconnect_poll: Instant::now(),
|
||||
last_port_check: Instant::now(),
|
||||
last_wpm_poll: Instant::now(),
|
||||
last_stats_refresh: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
283
src/advanced.rs
283
src/advanced.rs
|
|
@ -1,283 +0,0 @@
|
|||
use crate::context::{mod_idx_to_byte, AppContext, BgMsg};
|
||||
use crate::protocol;
|
||||
use crate::{AdvancedBridge, AppState, MainWindow};
|
||||
use slint::{ComponentHandle, Model, SharedString};
|
||||
|
||||
/// Wire up all advanced callbacks: refresh, delete combo/leader/KO,
|
||||
/// set trilayer, BT switch, TAMA action, toggle autoshift, create combo/KO/leader.
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
// --- Advanced: refresh ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<AdvancedBridge>().on_refresh_advanced(move || {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Ok(r) = ser.send_binary(cmd::TD_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::TdList(protocol::parsers::parse_td_binary(&r.payload)));
|
||||
}
|
||||
if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::ComboList(protocol::parsers::parse_combo_binary(&r.payload)));
|
||||
}
|
||||
if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::LeaderList(protocol::parsers::parse_leader_binary(&r.payload)));
|
||||
}
|
||||
if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::KoList(protocol::parsers::parse_ko_binary(&r.payload)));
|
||||
}
|
||||
if let Ok(r) = ser.send_binary(cmd::BT_QUERY, &[]) {
|
||||
let _ = tx.send(BgMsg::BtStatus(protocol::parsers::parse_bt_binary(&r.payload)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced: delete combo ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<AdvancedBridge>().on_delete_combo(move |idx| {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
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(protocol::parsers::parse_combo_binary(&r.payload)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced: delete leader ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<AdvancedBridge>().on_delete_leader(move |idx| {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
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(protocol::parsers::parse_leader_binary(&r.payload)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced: delete KO ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<AdvancedBridge>().on_delete_ko(move |idx| {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
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(protocol::parsers::parse_ko_binary(&r.payload)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced: set trilayer ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<AdvancedBridge>().on_set_trilayer(move |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_binary(protocol::binary::cmd::TRILAYER_SET, &payload);
|
||||
});
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
w.global::<AppState>().set_status_text(
|
||||
SharedString::from(format!("Tri-layer: {} + {} → {}", l1, l2, l3))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced: BT switch ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
|
||||
window.global::<AdvancedBridge>().on_bt_switch(move |slot| {
|
||||
let serial = serial.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = ser.send_binary(protocol::binary::cmd::BT_SWITCH, &[slot as u8]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced: TAMA action ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<AdvancedBridge>().on_tama_action(move |action| {
|
||||
use protocol::binary::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();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = ser.send_binary(action_cmd, &[]);
|
||||
if let Ok(r) = ser.send_binary(cmd::TAMA_QUERY, &[]) {
|
||||
let _ = tx.send(BgMsg::TamaStatus(protocol::parsers::parse_tama_binary(&r.payload)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced: toggle autoshift ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<AdvancedBridge>().on_toggle_autoshift(move || {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match ser.send_binary(protocol::binary::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)));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced: create combo ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<AdvancedBridge>().on_create_combo(move || {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let adv = w.global::<AdvancedBridge>();
|
||||
let r1 = adv.get_new_combo_r1() as u8;
|
||||
let c1 = adv.get_new_combo_c1() as u8;
|
||||
let r2 = adv.get_new_combo_r2() as u8;
|
||||
let c2 = adv.get_new_combo_c2() as u8;
|
||||
let result = adv.get_new_combo_result_code() as u8;
|
||||
let key1_name = adv.get_new_combo_key1_name();
|
||||
let key2_name = adv.get_new_combo_key2_name();
|
||||
if key1_name == "Pick..." || key2_name == "Pick..." {
|
||||
w.global::<AppState>().set_status_text("Pick both keys first".into());
|
||||
return;
|
||||
}
|
||||
let next_idx = adv.get_combos().row_count() as u8;
|
||||
let payload = protocol::binary::combo_set_payload(next_idx, r1, c1, r2, c2, result);
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = ser.send_binary(cmd::COMBO_SET, &payload);
|
||||
if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::ComboList(protocol::parsers::parse_combo_binary(&r.payload)));
|
||||
}
|
||||
});
|
||||
w.global::<AppState>().set_status_text("Creating combo...".into());
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced: create KO ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<AdvancedBridge>().on_create_ko(move || {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let adv = w.global::<AdvancedBridge>();
|
||||
let trig = adv.get_new_ko_trigger_code() as u8;
|
||||
let trig_mod = (adv.get_new_ko_trig_ctrl() as u8)
|
||||
| ((adv.get_new_ko_trig_shift() as u8) << 1)
|
||||
| ((adv.get_new_ko_trig_alt() as u8) << 2);
|
||||
let result = adv.get_new_ko_result_code() as u8;
|
||||
let res_mod = (adv.get_new_ko_res_ctrl() as u8)
|
||||
| ((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 payload = protocol::binary::ko_set_payload(next_idx, trig, trig_mod, result, res_mod);
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = ser.send_binary(cmd::KO_SET, &payload);
|
||||
if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::KoList(protocol::parsers::parse_ko_binary(&r.payload)));
|
||||
}
|
||||
});
|
||||
w.global::<AppState>().set_status_text("Creating key override...".into());
|
||||
});
|
||||
}
|
||||
|
||||
// --- Advanced: create leader ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<AdvancedBridge>().on_create_leader(move |result_code: i32, mod_idx: i32| {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let adv = w.global::<AdvancedBridge>();
|
||||
let count = adv.get_new_leader_seq_count() as usize;
|
||||
let mut sequence = Vec::new();
|
||||
if count > 0 { sequence.push(adv.get_new_leader_seq0_code() as u8); }
|
||||
if count > 1 { sequence.push(adv.get_new_leader_seq1_code() as u8); }
|
||||
if count > 2 { sequence.push(adv.get_new_leader_seq2_code() as u8); }
|
||||
if count > 3 { sequence.push(adv.get_new_leader_seq3_code() as u8); }
|
||||
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 payload = protocol::binary::leader_set_payload(next_idx, &sequence, result, result_mod);
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = ser.send_binary(cmd::LEADER_SET, &payload);
|
||||
if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::LeaderList(protocol::parsers::parse_leader_binary(&r.payload)));
|
||||
}
|
||||
});
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
w.global::<AppState>().set_status_text("Creating leader key...".into());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
211
src/config.rs
211
src/config.rs
|
|
@ -1,211 +0,0 @@
|
|||
use crate::context::BgMsg;
|
||||
use crate::protocol;
|
||||
use crate::protocol::serial::SerialManager;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Export all keyboard config to JSON file via rfd save dialog.
|
||||
pub fn export_config(
|
||||
serial: &Arc<Mutex<SerialManager>>,
|
||||
tx: &mpsc::Sender<BgMsg>,
|
||||
) -> Result<String, String> {
|
||||
use protocol::binary::cmd;
|
||||
use protocol::config_io::*;
|
||||
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 = protocol::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(),
|
||||
};
|
||||
|
||||
let _ = tx.send(BgMsg::ConfigProgress(0.50, "Reading combos...".into()));
|
||||
let combos = match ser.send_binary(cmd::COMBO_LIST, &[]) {
|
||||
Ok(resp) => {
|
||||
protocol::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(),
|
||||
};
|
||||
|
||||
let _ = tx.send(BgMsg::ConfigProgress(0.60, "Reading key overrides...".into()));
|
||||
let key_overrides = match ser.send_binary(cmd::KO_LIST, &[]) {
|
||||
Ok(resp) => {
|
||||
protocol::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(),
|
||||
};
|
||||
|
||||
let _ = tx.send(BgMsg::ConfigProgress(0.70, "Reading leaders...".into()));
|
||||
let leaders = match ser.send_binary(cmd::LEADER_LIST, &[]) {
|
||||
Ok(resp) => {
|
||||
protocol::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(),
|
||||
};
|
||||
|
||||
let _ = tx.send(BgMsg::ConfigProgress(0.80, "Reading macros...".into()));
|
||||
let macros = match ser.send_binary(cmd::LIST_MACROS, &[]) {
|
||||
Ok(resp) => {
|
||||
protocol::parsers::parse_macros_binary(&resp.payload).iter().map(|m| {
|
||||
let steps_str: Vec<String> = 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);
|
||||
|
||||
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.
|
||||
pub fn import_config(
|
||||
serial: &Arc<Mutex<SerialManager>>,
|
||||
tx: &mpsc::Sender<BgMsg>,
|
||||
config: &protocol::config_io::KeyboardConfig,
|
||||
) -> Result<String, String> {
|
||||
use protocol::binary 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()
|
||||
+ 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
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::<usize>())
|
||||
.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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
use crate::context::{AppContext, BgMsg};
|
||||
use crate::protocol;
|
||||
use crate::{AppState, ConnectionBridge, ConnectionState, MainWindow};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
/// Auto-connect to the keyboard on startup.
|
||||
pub fn auto_connect(window: &MainWindow, ctx: &AppContext) {
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
window.global::<AppState>().set_status_text("Scanning ports...".into());
|
||||
window.global::<AppState>().set_connection(ConnectionState::Connecting);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match ser.auto_connect() {
|
||||
Ok(port_name) => {
|
||||
let fw = ser.get_firmware_version().unwrap_or_default();
|
||||
let names = ser.get_layer_names().unwrap_or_default();
|
||||
let km = ser.get_keymap(0).unwrap_or_default();
|
||||
let _ = tx.send(BgMsg::Connected(port_name, fw, names, km));
|
||||
// Fetch physical layout from firmware
|
||||
match ser.get_layout_json() {
|
||||
Ok(json) => {
|
||||
match protocol::layout::parse_json(&json) {
|
||||
Ok(keys) => { let _ = tx.send(BgMsg::LayoutJson(keys)); }
|
||||
Err(e) => eprintln!("Layout parse error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("get_layout_json error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(BgMsg::ConnectError(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Wire up Connect, Disconnect, refresh_ports, and tab-change auto-refresh.
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
// --- Connect callback ---
|
||||
{
|
||||
let serial_c = ctx.serial.clone();
|
||||
let tx_c = ctx.bg_tx.clone();
|
||||
let window_weak = window.as_weak();
|
||||
window.global::<ConnectionBridge>().on_connect(move || {
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
w.global::<AppState>().set_status_text("Scanning ports...".into());
|
||||
w.global::<AppState>().set_connection(ConnectionState::Connecting);
|
||||
}
|
||||
let serial = serial_c.clone();
|
||||
let tx = tx_c.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match ser.auto_connect() {
|
||||
Ok(port_name) => {
|
||||
let fw = ser.get_firmware_version().unwrap_or_default();
|
||||
let names = ser.get_layer_names().unwrap_or_default();
|
||||
let km = ser.get_keymap(0).unwrap_or_default();
|
||||
let _ = tx.send(BgMsg::Connected(port_name, fw, names, km));
|
||||
match ser.get_layout_json() {
|
||||
Ok(json) => {
|
||||
match protocol::layout::parse_json(&json) {
|
||||
Ok(keys) => { let _ = tx.send(BgMsg::LayoutJson(keys)); }
|
||||
Err(e) => eprintln!("Layout parse error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("get_layout_json error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => { let _ = tx.send(BgMsg::ConnectError(e)); }
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Disconnect callback ---
|
||||
{
|
||||
let serial_d = ctx.serial.clone();
|
||||
let tx_d = ctx.bg_tx.clone();
|
||||
window.global::<ConnectionBridge>().on_disconnect(move || {
|
||||
let mut ser = serial_d.lock().unwrap_or_else(|e| e.into_inner());
|
||||
ser.disconnect();
|
||||
let _ = tx_d.send(BgMsg::Disconnected);
|
||||
});
|
||||
}
|
||||
|
||||
window.global::<ConnectionBridge>().on_refresh_ports(|| {});
|
||||
|
||||
// --- Auto-refresh on tab change ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<AppState>().on_tab_changed(move |tab_idx| {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
if w.global::<AppState>().get_connection() != ConnectionState::Connected { return; }
|
||||
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
match tab_idx {
|
||||
1 => {
|
||||
// Advanced: refresh TD, combo, leader, KO, BT via binary
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Ok(r) = ser.send_binary(cmd::TD_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::TdList(protocol::parsers::parse_td_binary(&r.payload)));
|
||||
}
|
||||
if let Ok(r) = ser.send_binary(cmd::COMBO_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::ComboList(protocol::parsers::parse_combo_binary(&r.payload)));
|
||||
}
|
||||
if let Ok(r) = ser.send_binary(cmd::LEADER_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::LeaderList(protocol::parsers::parse_leader_binary(&r.payload)));
|
||||
}
|
||||
if let Ok(r) = ser.send_binary(cmd::KO_LIST, &[]) {
|
||||
let _ = tx.send(BgMsg::KoList(protocol::parsers::parse_ko_binary(&r.payload)));
|
||||
}
|
||||
if let Ok(r) = ser.send_binary(cmd::BT_QUERY, &[]) {
|
||||
let _ = tx.send(BgMsg::BtStatus(protocol::parsers::parse_bt_binary(&r.payload)));
|
||||
}
|
||||
});
|
||||
}
|
||||
2 => {
|
||||
// Macros: refresh via binary
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Ok(resp) = ser.send_binary(protocol::binary::cmd::LIST_MACROS, &[]) {
|
||||
let macros = protocol::parsers::parse_macros_binary(&resp.payload);
|
||||
let _ = tx.send(BgMsg::MacroList(macros));
|
||||
}
|
||||
});
|
||||
}
|
||||
3 => {
|
||||
// Stats: refresh heatmap + bigrams via binary
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Ok(r) = ser.send_binary(cmd::KEYSTATS_BIN, &[]) {
|
||||
let (data, max) = protocol::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(protocol::binary::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));
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
use crate::protocol::layout::KeycapPos;
|
||||
use crate::protocol::layout_remap::KeyboardLayout;
|
||||
use crate::protocol::parsers;
|
||||
use crate::protocol::serial::SerialManager;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Shared application state passed to all bridge setup functions.
|
||||
///
|
||||
/// Thread-safe fields (`serial`, `bg_tx`) are cloned into background threads.
|
||||
/// Main-thread fields (`Rc<RefCell<…>>`) stay on the UI thread only.
|
||||
pub struct AppContext {
|
||||
pub serial: Arc<Mutex<SerialManager>>,
|
||||
pub bg_tx: mpsc::Sender<BgMsg>,
|
||||
pub keys: Rc<RefCell<Vec<KeycapPos>>>,
|
||||
pub current_keymap: Rc<RefCell<Vec<Vec<u16>>>>,
|
||||
pub current_layer: Rc<Cell<usize>>,
|
||||
pub keyboard_layout: Rc<RefCell<KeyboardLayout>>,
|
||||
pub heatmap_data: Rc<RefCell<Vec<Vec<u32>>>>,
|
||||
pub macro_steps: Rc<RefCell<Vec<(u8, u8)>>>,
|
||||
}
|
||||
|
||||
/// Messages sent from background serial threads to the UI event loop.
|
||||
pub enum BgMsg {
|
||||
Connected(String, String, Vec<String>, Vec<Vec<u16>>), // port, fw_version, layer_names, keymap
|
||||
ConnectError(String),
|
||||
Keymap(Vec<Vec<u16>>),
|
||||
LayerNames(Vec<String>),
|
||||
Disconnected,
|
||||
#[allow(dead_code)]
|
||||
TextLines(String, Vec<String>),
|
||||
HeatmapData(Vec<Vec<u32>>, u32), // counts, max
|
||||
BigramLines(Vec<String>),
|
||||
LayoutJson(Vec<KeycapPos>),
|
||||
MacroList(Vec<parsers::MacroEntry>),
|
||||
TdList(Vec<[u16; 4]>),
|
||||
ComboList(Vec<parsers::ComboEntry>),
|
||||
LeaderList(Vec<parsers::LeaderEntry>),
|
||||
KoList(Vec<[u8; 4]>),
|
||||
BtStatus(Vec<String>),
|
||||
TamaStatus(Vec<String>),
|
||||
AutoshiftStatus(String),
|
||||
Wpm(u16),
|
||||
FlashProgress(f32, String),
|
||||
FlashDone(Result<(), String>),
|
||||
OtaProgress(f32, String),
|
||||
OtaDone(Result<(), String>),
|
||||
ConfigProgress(f32, String),
|
||||
ConfigDone(Result<String, String>),
|
||||
MatrixTestToggled(bool, u8, u8), // enabled, rows, cols
|
||||
MatrixTestEvent(u8, u8, u8), // row, col, state (1=pressed, 0=released)
|
||||
MatrixTestError(String),
|
||||
}
|
||||
|
||||
/// Spawn a background thread that locks the serial port and runs `f`.
|
||||
#[allow(dead_code)]
|
||||
///
|
||||
/// Eliminates the 4-line clone+spawn+lock boilerplate repeated 30+ times.
|
||||
pub fn serial_spawn<F>(ctx: &AppContext, f: F)
|
||||
where
|
||||
F: FnOnce(&mut SerialManager, &mpsc::Sender<BgMsg>) + Send + 'static,
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f(&mut ser, &tx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Map ComboBox modifier index to HID modifier byte.
|
||||
/// [None, Ctrl, Shift, Alt, GUI, RCtrl, RShift, RAlt, RGUI]
|
||||
pub fn mod_idx_to_byte(idx: i32) -> u8 {
|
||||
match idx {
|
||||
1 => 0x01, 2 => 0x02, 3 => 0x04, 4 => 0x08,
|
||||
5 => 0x10, 6 => 0x20, 7 => 0x40, 8 => 0x80,
|
||||
_ => 0x00,
|
||||
}
|
||||
}
|
||||
417
src/dispatch.rs
417
src/dispatch.rs
|
|
@ -1,417 +0,0 @@
|
|||
use crate::context::{AppContext, BgMsg};
|
||||
use crate::protocol::{self as protocol, keycode};
|
||||
use crate::models;
|
||||
use crate::{
|
||||
AdvancedBridge, AppState, BigramData, ComboData, ConnectionState, FingerLoadData,
|
||||
FlasherBridge, HandBalanceData, KeyOverrideData, KeymapBridge, LayoutBridge, LeaderData,
|
||||
MacroBridge, MacroData, MainWindow, RowUsageData, SettingsBridge, StatsBridge,
|
||||
TapDanceAction, TapDanceData, ToolsBridge, TopKeyData, LayerInfo,
|
||||
};
|
||||
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// Start the event loop with BgMsg polling, WPM timer, and layout auto-refresh.
|
||||
pub fn run(window: &MainWindow, ctx: &AppContext, bg_rx: mpsc::Receiver<BgMsg>) {
|
||||
let window_weak = window.as_weak();
|
||||
let keys_arc = ctx.keys.clone();
|
||||
let current_keymap = ctx.current_keymap.clone();
|
||||
let keyboard_layout = ctx.keyboard_layout.clone();
|
||||
let heatmap_data = ctx.heatmap_data.clone();
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
std::time::Duration::from_millis(50),
|
||||
move || {
|
||||
let Some(window) = window_weak.upgrade() else { return };
|
||||
|
||||
while let Ok(msg) = bg_rx.try_recv() {
|
||||
handle_msg(&window, msg, &keys_arc, ¤t_keymap, &keyboard_layout, &heatmap_data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// WPM polling timer (5s)
|
||||
let wpm_timer = slint::Timer::default();
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let window_weak2 = window.as_weak();
|
||||
|
||||
wpm_timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
std::time::Duration::from_secs(5),
|
||||
move || {
|
||||
let Some(w) = window_weak2.upgrade() else { return };
|
||||
if w.global::<AppState>().get_connection() != ConnectionState::Connected { return; }
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let Ok(mut ser) = serial.try_lock() else { return };
|
||||
if let Ok(r) = ser.send_binary(protocol::binary::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));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 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::<LayoutBridge>();
|
||||
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) {
|
||||
models::populate_layout_preview(&w, &json);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let _keep_timer = timer;
|
||||
let _keep_wpm = wpm_timer;
|
||||
let _keep_layout = layout_timer;
|
||||
window.run().unwrap();
|
||||
}
|
||||
|
||||
fn handle_msg(
|
||||
window: &MainWindow,
|
||||
msg: BgMsg,
|
||||
keys_arc: &Rc<std::cell::RefCell<Vec<crate::protocol::layout::KeycapPos>>>,
|
||||
current_keymap: &Rc<std::cell::RefCell<Vec<Vec<u16>>>>,
|
||||
keyboard_layout: &Rc<std::cell::RefCell<protocol::layout_remap::KeyboardLayout>>,
|
||||
heatmap_data: &Rc<std::cell::RefCell<Vec<Vec<u32>>>>,
|
||||
) {
|
||||
match msg {
|
||||
BgMsg::Connected(port, fw, names, km) => {
|
||||
let app = window.global::<AppState>();
|
||||
app.set_connection(ConnectionState::Connected);
|
||||
app.set_firmware_version(SharedString::from(&fw));
|
||||
app.set_status_text(SharedString::from(format!("Connected to {}", port)));
|
||||
|
||||
let new_layers = models::build_layer_model(&names);
|
||||
window.global::<KeymapBridge>().set_layers(ModelRc::from(new_layers));
|
||||
|
||||
*current_keymap.borrow_mut() = km.clone();
|
||||
let keycaps = window.global::<KeymapBridge>().get_keycaps();
|
||||
let layout = keyboard_layout.borrow();
|
||||
let keys = keys_arc.borrow();
|
||||
models::update_keycap_labels(&keycaps, &keys, &km, &layout);
|
||||
}
|
||||
BgMsg::ConnectError(e) => {
|
||||
let app = window.global::<AppState>();
|
||||
app.set_connection(ConnectionState::Disconnected);
|
||||
app.set_status_text(SharedString::from(format!("Error: {}", e)));
|
||||
}
|
||||
BgMsg::Keymap(km) => {
|
||||
*current_keymap.borrow_mut() = km.clone();
|
||||
let keycaps = window.global::<KeymapBridge>().get_keycaps();
|
||||
let layout = keyboard_layout.borrow();
|
||||
let keys = keys_arc.borrow();
|
||||
models::update_keycap_labels(&keycaps, &keys, &km, &layout);
|
||||
window.global::<AppState>().set_status_text("Keymap loaded".into());
|
||||
}
|
||||
BgMsg::LayerNames(names) => {
|
||||
let active = window.global::<KeymapBridge>().get_active_layer() as usize;
|
||||
let layers: Vec<LayerInfo> = names.iter().enumerate().map(|(i, name)| LayerInfo {
|
||||
index: i as i32,
|
||||
name: SharedString::from(name.as_str()),
|
||||
active: i == active,
|
||||
}).collect();
|
||||
window.global::<KeymapBridge>().set_layers(
|
||||
ModelRc::from(Rc::new(VecModel::from(layers)))
|
||||
);
|
||||
}
|
||||
BgMsg::Disconnected => {
|
||||
let app = window.global::<AppState>();
|
||||
app.set_connection(ConnectionState::Disconnected);
|
||||
app.set_firmware_version(SharedString::default());
|
||||
app.set_status_text("Disconnected".into());
|
||||
}
|
||||
BgMsg::LayoutJson(new_keys) => {
|
||||
*keys_arc.borrow_mut() = new_keys.clone();
|
||||
let new_model = models::build_keycap_model(&new_keys);
|
||||
let km = current_keymap.borrow();
|
||||
if !km.is_empty() {
|
||||
let layout = keyboard_layout.borrow();
|
||||
models::update_keycap_labels(&new_model, &new_keys, &km, &layout);
|
||||
}
|
||||
let mut max_x: f32 = 0.0;
|
||||
let mut max_y: f32 = 0.0;
|
||||
for kp in &new_keys {
|
||||
if kp.x + kp.w > max_x { max_x = kp.x + kp.w; }
|
||||
if kp.y + kp.h > max_y { max_y = kp.y + kp.h; }
|
||||
}
|
||||
let bridge = window.global::<KeymapBridge>();
|
||||
bridge.set_content_width(max_x);
|
||||
bridge.set_content_height(max_y);
|
||||
bridge.set_keycaps(ModelRc::from(new_model));
|
||||
window.global::<AppState>().set_status_text(
|
||||
SharedString::from(format!("Layout loaded ({} keys)", new_keys.len()))
|
||||
);
|
||||
}
|
||||
BgMsg::BigramLines(lines) => {
|
||||
let entries = protocol::stats::parse_bigram_lines(&lines);
|
||||
let analysis = protocol::stats::analyze_bigrams(&entries);
|
||||
window.global::<StatsBridge>().set_bigrams(BigramData {
|
||||
alt_hand_pct: analysis.alt_hand_pct,
|
||||
same_hand_pct: analysis.same_hand_pct,
|
||||
sfb_pct: analysis.sfb_pct,
|
||||
total: analysis.total as i32,
|
||||
});
|
||||
}
|
||||
BgMsg::FlashProgress(progress, msg) => {
|
||||
let f = window.global::<FlasherBridge>();
|
||||
f.set_flash_progress(progress);
|
||||
f.set_flash_status(SharedString::from(msg));
|
||||
}
|
||||
BgMsg::FlashDone(result) => {
|
||||
let f = window.global::<FlasherBridge>();
|
||||
f.set_flashing(false);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
f.set_flash_progress(1.0);
|
||||
f.set_flash_status(SharedString::from("Flash complete!"));
|
||||
window.global::<AppState>().set_status_text("Flash complete!".into());
|
||||
}
|
||||
Err(e) => {
|
||||
f.set_flash_status(SharedString::from(format!("Error: {}", e)));
|
||||
window.global::<AppState>().set_status_text(
|
||||
SharedString::from(format!("Flash error: {}", e))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
BgMsg::HeatmapData(data, max) => {
|
||||
*heatmap_data.borrow_mut() = data.clone();
|
||||
let keycaps = window.global::<KeymapBridge>().get_keycaps();
|
||||
let keys = keys_arc.borrow();
|
||||
for i in 0..keycaps.row_count() {
|
||||
if i >= keys.len() { break; }
|
||||
let mut item = keycaps.row_data(i).unwrap();
|
||||
let kp = &keys[i];
|
||||
let count = data.get(kp.row).and_then(|r| r.get(kp.col)).copied().unwrap_or(0);
|
||||
item.heat = if max > 0 { count as f32 / max as f32 } else { 0.0 };
|
||||
keycaps.set_row_data(i, item);
|
||||
}
|
||||
drop(keys);
|
||||
|
||||
let km = current_keymap.borrow();
|
||||
let balance = protocol::stats::hand_balance(&data);
|
||||
let fingers = protocol::stats::finger_load(&data);
|
||||
let rows = protocol::stats::row_usage(&data);
|
||||
let top = protocol::stats::top_keys(&data, &km, 10);
|
||||
let dead = protocol::stats::dead_keys(&data, &km);
|
||||
|
||||
let stats = window.global::<StatsBridge>();
|
||||
stats.set_hand_balance(HandBalanceData {
|
||||
left_pct: balance.left_pct, right_pct: balance.right_pct, total: balance.total as i32,
|
||||
});
|
||||
stats.set_total_presses(balance.total as i32);
|
||||
stats.set_finger_load(ModelRc::from(Rc::new(VecModel::from(
|
||||
fingers.iter().map(|f| FingerLoadData {
|
||||
name: SharedString::from(&f.name), pct: f.pct, count: f.count as i32,
|
||||
}).collect::<Vec<_>>()
|
||||
))));
|
||||
stats.set_row_usage(ModelRc::from(Rc::new(VecModel::from(
|
||||
rows.iter().map(|r| RowUsageData {
|
||||
name: SharedString::from(&r.name), pct: r.pct, count: r.count as i32,
|
||||
}).collect::<Vec<_>>()
|
||||
))));
|
||||
stats.set_top_keys(ModelRc::from(Rc::new(VecModel::from(
|
||||
top.iter().map(|t| TopKeyData {
|
||||
name: SharedString::from(&t.name), finger: SharedString::from(&t.finger),
|
||||
count: t.count as i32, pct: t.pct,
|
||||
}).collect::<Vec<_>>()
|
||||
))));
|
||||
stats.set_dead_keys(ModelRc::from(Rc::new(VecModel::from(
|
||||
dead.iter().map(|d| SharedString::from(d.as_str())).collect::<Vec<_>>()
|
||||
))));
|
||||
window.global::<AppState>().set_status_text(
|
||||
SharedString::from(format!("Stats loaded ({} total presses, max {})", balance.total, max))
|
||||
);
|
||||
}
|
||||
BgMsg::TextLines(_tag, _lines) => {}
|
||||
BgMsg::TdList(td_data) => {
|
||||
let model: Vec<TapDanceData> = 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::<Vec<_>>()
|
||||
))),
|
||||
}).collect();
|
||||
window.global::<AdvancedBridge>().set_tap_dances(
|
||||
ModelRc::from(Rc::new(VecModel::from(model)))
|
||||
);
|
||||
}
|
||||
BgMsg::ComboList(combo_data) => {
|
||||
let model: Vec<ComboData> = 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::<AdvancedBridge>().set_combos(
|
||||
ModelRc::from(Rc::new(VecModel::from(model)))
|
||||
);
|
||||
}
|
||||
BgMsg::LeaderList(leader_data) => {
|
||||
let model: Vec<LeaderData> = leader_data.iter().map(|l| {
|
||||
let seq: Vec<String> = 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::<AdvancedBridge>().set_leaders(
|
||||
ModelRc::from(Rc::new(VecModel::from(model)))
|
||||
);
|
||||
}
|
||||
BgMsg::KoList(ko_data) => {
|
||||
let model: Vec<KeyOverrideData> = 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::<AdvancedBridge>().set_key_overrides(
|
||||
ModelRc::from(Rc::new(VecModel::from(model)))
|
||||
);
|
||||
}
|
||||
BgMsg::BtStatus(lines) => {
|
||||
window.global::<AdvancedBridge>().set_bt_status(SharedString::from(lines.join("\n")));
|
||||
}
|
||||
BgMsg::TamaStatus(lines) => {
|
||||
window.global::<AdvancedBridge>().set_tama_status(SharedString::from(lines.join("\n")));
|
||||
}
|
||||
BgMsg::AutoshiftStatus(text) => {
|
||||
window.global::<AdvancedBridge>().set_autoshift_status(SharedString::from(text));
|
||||
}
|
||||
BgMsg::Wpm(wpm) => {
|
||||
window.global::<AppState>().set_wpm(wpm as i32);
|
||||
}
|
||||
BgMsg::OtaProgress(progress, msg) => {
|
||||
let s = window.global::<SettingsBridge>();
|
||||
s.set_ota_progress(progress);
|
||||
s.set_ota_status(SharedString::from(msg));
|
||||
}
|
||||
BgMsg::OtaDone(result) => {
|
||||
let s = window.global::<SettingsBridge>();
|
||||
s.set_ota_flashing(false);
|
||||
match result {
|
||||
Ok(()) => { s.set_ota_progress(1.0); s.set_ota_status("OTA complete!".into()); }
|
||||
Err(e) => { s.set_ota_status(SharedString::from(format!("OTA error: {}", e))); }
|
||||
}
|
||||
}
|
||||
BgMsg::ConfigProgress(progress, msg) => {
|
||||
let s = window.global::<SettingsBridge>();
|
||||
s.set_config_progress(progress);
|
||||
s.set_config_status(SharedString::from(msg));
|
||||
}
|
||||
BgMsg::ConfigDone(result) => {
|
||||
let s = window.global::<SettingsBridge>();
|
||||
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::MatrixTestToggled(enabled, _rows, _cols) => {
|
||||
let tb = window.global::<ToolsBridge>();
|
||||
tb.set_matrix_test_active(enabled);
|
||||
if enabled {
|
||||
// Build matrix keycap model from current keys
|
||||
let keys = keys_arc.borrow();
|
||||
let model = models::build_keycap_model(&keys);
|
||||
// Reset all colors to default (grey)
|
||||
for i in 0..model.row_count() {
|
||||
let mut item = model.row_data(i).unwrap();
|
||||
item.color = slint::Color::from_argb_u8(255, 0x44, 0x47, 0x5a);
|
||||
item.label = SharedString::from(format!("R{}C{}", keys[i].row, keys[i].col));
|
||||
item.heat = 0.0;
|
||||
model.set_row_data(i, item);
|
||||
}
|
||||
let mut max_x: f32 = 0.0;
|
||||
let mut max_y: f32 = 0.0;
|
||||
for kp in keys.iter() {
|
||||
if kp.x + kp.w > max_x { max_x = kp.x + kp.w; }
|
||||
if kp.y + kp.h > max_y { max_y = kp.y + kp.h; }
|
||||
}
|
||||
tb.set_matrix_content_width(max_x);
|
||||
tb.set_matrix_content_height(max_y);
|
||||
tb.set_matrix_keycaps(ModelRc::from(model));
|
||||
tb.set_matrix_test_status(SharedString::from("Matrix test active — press keys"));
|
||||
} else {
|
||||
tb.set_matrix_test_status(SharedString::from("Matrix test stopped"));
|
||||
}
|
||||
}
|
||||
BgMsg::MatrixTestEvent(row, col, state) => {
|
||||
let tb = window.global::<ToolsBridge>();
|
||||
let keycaps = tb.get_matrix_keycaps();
|
||||
let keys = keys_arc.borrow();
|
||||
// Find the keycap matching this row/col
|
||||
for i in 0..keycaps.row_count() {
|
||||
if i >= keys.len() { break; }
|
||||
if keys[i].row == row as usize && keys[i].col == col as usize {
|
||||
let mut item = keycaps.row_data(i).unwrap();
|
||||
if state == 1 {
|
||||
item.color = slint::Color::from_argb_u8(255, 0x50, 0xFA, 0x7B); // green = pressed
|
||||
} else {
|
||||
item.color = slint::Color::from_argb_u8(255, 0xBD, 0x93, 0xF9); // purple = was activated
|
||||
}
|
||||
keycaps.set_row_data(i, item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
BgMsg::MatrixTestError(e) => {
|
||||
let tb = window.global::<ToolsBridge>();
|
||||
tb.set_matrix_test_active(false);
|
||||
tb.set_matrix_test_status(SharedString::from(format!("Error: {}", e)));
|
||||
}
|
||||
BgMsg::MacroList(macros) => {
|
||||
let model: Vec<MacroData> = macros.iter().map(|m| {
|
||||
let steps_str: Vec<String> = m.steps.iter().map(|s| {
|
||||
if s.is_delay() { format!("T({})", s.delay_ms()) }
|
||||
else { keycode::hid_key_name(s.keycode).to_string() }
|
||||
}).collect();
|
||||
MacroData {
|
||||
slot: m.slot as i32,
|
||||
name: SharedString::from(&m.name),
|
||||
steps: SharedString::from(steps_str.join(" ")),
|
||||
}
|
||||
}).collect();
|
||||
let max_slot = model.iter().fold(-1i32, |acc, m| acc.max(m.slot));
|
||||
let mb = window.global::<MacroBridge>();
|
||||
mb.set_macros(ModelRc::from(Rc::new(VecModel::from(model))));
|
||||
let next_slot = max_slot + 1;
|
||||
if next_slot > mb.get_new_slot_idx() {
|
||||
mb.set_new_slot_idx(next_slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/flasher.rs
119
src/flasher.rs
|
|
@ -1,119 +0,0 @@
|
|||
use crate::context::{AppContext, BgMsg};
|
||||
use crate::protocol;
|
||||
use crate::protocol::serial::SerialManager;
|
||||
use crate::{FlasherBridge, MainWindow};
|
||||
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
init_prog_ports(window);
|
||||
setup_refresh_prog_ports(window);
|
||||
setup_browse_firmware(window);
|
||||
setup_flash(window, ctx);
|
||||
}
|
||||
|
||||
// Init prog ports list on startup
|
||||
fn init_prog_ports(window: &MainWindow) {
|
||||
let ports = SerialManager::list_prog_ports();
|
||||
if let Some(first) = ports.first() {
|
||||
window.global::<FlasherBridge>().set_selected_prog_port(SharedString::from(first.as_str()));
|
||||
}
|
||||
let model: Vec<SharedString> = ports.iter().map(|p| SharedString::from(p.as_str())).collect();
|
||||
window.global::<FlasherBridge>().set_prog_ports(
|
||||
ModelRc::from(Rc::new(VecModel::from(model)))
|
||||
);
|
||||
}
|
||||
|
||||
// --- Flasher: refresh prog ports ---
|
||||
fn setup_refresh_prog_ports(window: &MainWindow) {
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<FlasherBridge>().on_refresh_prog_ports(move || {
|
||||
let ports = SerialManager::list_prog_ports();
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
if let Some(first) = ports.first() {
|
||||
w.global::<FlasherBridge>().set_selected_prog_port(SharedString::from(first.as_str()));
|
||||
}
|
||||
let model: Vec<SharedString> = ports.iter().map(|p| SharedString::from(p.as_str())).collect();
|
||||
w.global::<FlasherBridge>().set_prog_ports(
|
||||
ModelRc::from(Rc::new(VecModel::from(model)))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Flasher: browse firmware ---
|
||||
fn setup_browse_firmware(window: &MainWindow) {
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<FlasherBridge>().on_browse_firmware(move || {
|
||||
let window_weak = window_weak.clone();
|
||||
std::thread::spawn(move || {
|
||||
let file = rfd::FileDialog::new()
|
||||
.add_filter("Firmware", &["bin"])
|
||||
.pick_file();
|
||||
if let Some(path) = file {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let _ = slint::invoke_from_event_loop(move || {
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
w.global::<FlasherBridge>().set_firmware_path(
|
||||
SharedString::from(path_str.as_str())
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Flasher: flash ---
|
||||
fn setup_flash(window: &MainWindow, ctx: &AppContext) {
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<FlasherBridge>().on_flash(move || {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let flasher = w.global::<FlasherBridge>();
|
||||
let port = flasher.get_selected_prog_port().to_string();
|
||||
let path = flasher.get_firmware_path().to_string();
|
||||
let offset: u32 = match flasher.get_flash_offset_index() {
|
||||
0 => 0x0, // full flash
|
||||
1 => 0x20000, // factory
|
||||
2 => 0x220000, // ota_0
|
||||
_ => 0x20000,
|
||||
};
|
||||
|
||||
if port.is_empty() || path.is_empty() { return; }
|
||||
|
||||
// Read firmware file
|
||||
let firmware = match std::fs::read(&path) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
let _ = tx.send(BgMsg::FlashDone(Err(format!("Cannot read {}: {}", path, e))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
flasher.set_flashing(true);
|
||||
flasher.set_flash_progress(0.0);
|
||||
flasher.set_flash_status(SharedString::from("Starting..."));
|
||||
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let (ftx, frx) = mpsc::channel();
|
||||
// Forward flash progress to main bg channel
|
||||
let tx2 = tx.clone();
|
||||
let progress_thread = std::thread::spawn(move || {
|
||||
while let Ok(protocol::flasher::FlashProgress::OtaProgress(p, msg)) = frx.recv() {
|
||||
let _ = tx2.send(BgMsg::FlashProgress(p, msg));
|
||||
}
|
||||
});
|
||||
|
||||
let result = protocol::flasher::flash_firmware(&port, &firmware, offset, &ftx);
|
||||
drop(ftx); // close channel so progress_thread exits
|
||||
let _ = progress_thread.join();
|
||||
let _ = tx.send(BgMsg::FlashDone(result.map_err(|e| e.to_string())));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
use crate::context::AppContext;
|
||||
use crate::protocol::{self as protocol, keycode};
|
||||
use crate::models;
|
||||
use crate::{
|
||||
AdvancedBridge, AppState, KeySelectorBridge, KeymapBridge, MainWindow,
|
||||
};
|
||||
use slint::{ComponentHandle, Model, SharedString};
|
||||
use std::rc::Rc;
|
||||
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
setup_filter(window, ctx);
|
||||
let apply_keycode = build_apply_keycode(window, ctx);
|
||||
let refresh_macro_display = crate::macros::make_refresh_display(window, ctx);
|
||||
let dispatch_keycode = build_dispatch_keycode(window, ctx, apply_keycode, refresh_macro_display);
|
||||
setup_callbacks(window, dispatch_keycode);
|
||||
}
|
||||
|
||||
fn setup_filter(window: &MainWindow, ctx: &AppContext) {
|
||||
let keyboard_layout = ctx.keyboard_layout.clone();
|
||||
let window_weak = window.as_weak();
|
||||
window.global::<KeySelectorBridge>().on_apply_filter(move |search| {
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
let layout = *keyboard_layout.borrow();
|
||||
let all_keys = models::build_key_entries_with_layout(&layout);
|
||||
models::populate_key_categories(&w, &all_keys, &search);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_apply_keycode(window: &MainWindow, ctx: &AppContext) -> Rc<dyn Fn(u16)> {
|
||||
let serial = ctx.serial.clone();
|
||||
let keys_arc = ctx.keys.clone();
|
||||
let current_keymap = ctx.current_keymap.clone();
|
||||
let current_layer = ctx.current_layer.clone();
|
||||
let keyboard_layout = ctx.keyboard_layout.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
Rc::new(move |code: u16| {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let key_idx = w.global::<KeymapBridge>().get_selected_key_index();
|
||||
if key_idx < 0 { return; }
|
||||
let key_idx = key_idx as usize;
|
||||
let keys = keys_arc.borrow();
|
||||
if key_idx >= keys.len() { return; }
|
||||
|
||||
let kp = &keys[key_idx];
|
||||
let row = kp.row;
|
||||
let col = kp.col;
|
||||
drop(keys);
|
||||
let layer = current_layer.get() as u8;
|
||||
|
||||
{
|
||||
let mut km = current_keymap.borrow_mut();
|
||||
if row < km.len() && col < km[row].len() {
|
||||
km[row][col] = code;
|
||||
}
|
||||
}
|
||||
|
||||
let layout = *keyboard_layout.borrow();
|
||||
let km = current_keymap.borrow().clone();
|
||||
let keys = keys_arc.borrow().clone();
|
||||
let keycaps = w.global::<KeymapBridge>().get_keycaps();
|
||||
models::update_keycap_labels(&keycaps, &keys, &km, &layout);
|
||||
|
||||
let payload = protocol::binary::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_binary(protocol::binary::cmd::SETKEY, &payload);
|
||||
});
|
||||
|
||||
w.global::<AppState>().set_status_text(
|
||||
SharedString::from(format!("[{},{}] = 0x{:04X}", row, col, code))
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
fn build_dispatch_keycode(
|
||||
window: &MainWindow,
|
||||
ctx: &AppContext,
|
||||
apply_keycode: Rc<dyn Fn(u16)>,
|
||||
refresh_macro_display: Rc<dyn Fn()>,
|
||||
) -> Rc<dyn Fn(u16)> {
|
||||
let keys_arc = ctx.keys.clone();
|
||||
let serial = ctx.serial.clone();
|
||||
let macro_steps = ctx.macro_steps.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
Rc::new(move |code: u16| {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let target = w.global::<KeymapBridge>().get_selector_target();
|
||||
let name = SharedString::from(keycode::decode_keycode(code));
|
||||
|
||||
match target.as_str() {
|
||||
"keymap" => { apply_keycode(code); }
|
||||
"combo-result" => {
|
||||
let adv = w.global::<AdvancedBridge>();
|
||||
adv.set_new_combo_result_code(code as i32);
|
||||
adv.set_new_combo_result_name(name);
|
||||
}
|
||||
"ko-trigger" => {
|
||||
let adv = w.global::<AdvancedBridge>();
|
||||
adv.set_new_ko_trigger_code(code as i32);
|
||||
adv.set_new_ko_trigger_name(name);
|
||||
}
|
||||
"ko-result" => {
|
||||
let adv = w.global::<AdvancedBridge>();
|
||||
adv.set_new_ko_result_code(code as i32);
|
||||
adv.set_new_ko_result_name(name);
|
||||
}
|
||||
"leader-result" => {
|
||||
let adv = w.global::<AdvancedBridge>();
|
||||
adv.set_new_leader_result_code(code as i32);
|
||||
adv.set_new_leader_result_name(name);
|
||||
}
|
||||
"combo-key1" | "combo-key2" => {
|
||||
let keys = keys_arc.borrow();
|
||||
let idx = code as usize;
|
||||
if idx < keys.len() {
|
||||
let kp = &keys[idx];
|
||||
let adv = w.global::<AdvancedBridge>();
|
||||
let label = SharedString::from(format!("R{}C{}", kp.row, kp.col));
|
||||
if target.as_str() == "combo-key1" {
|
||||
adv.set_new_combo_r1(kp.row as i32);
|
||||
adv.set_new_combo_c1(kp.col as i32);
|
||||
adv.set_new_combo_key1_name(label);
|
||||
} else {
|
||||
adv.set_new_combo_r2(kp.row as i32);
|
||||
adv.set_new_combo_c2(kp.col as i32);
|
||||
adv.set_new_combo_key2_name(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
"leader-seq" => {
|
||||
let adv = w.global::<AdvancedBridge>();
|
||||
let count = adv.get_new_leader_seq_count();
|
||||
match count {
|
||||
0 => { adv.set_new_leader_seq0_code(code as i32); adv.set_new_leader_seq0_name(name); }
|
||||
1 => { adv.set_new_leader_seq1_code(code as i32); adv.set_new_leader_seq1_name(name); }
|
||||
2 => { adv.set_new_leader_seq2_code(code as i32); adv.set_new_leader_seq2_name(name); }
|
||||
3 => { adv.set_new_leader_seq3_code(code as i32); adv.set_new_leader_seq3_name(name); }
|
||||
_ => {}
|
||||
}
|
||||
if count < 4 { adv.set_new_leader_seq_count(count + 1); }
|
||||
}
|
||||
"td-action" => {
|
||||
let adv = w.global::<AdvancedBridge>();
|
||||
let td_idx = adv.get_editing_td_index();
|
||||
let slot = adv.get_editing_td_slot() as usize;
|
||||
if td_idx >= 0 && slot < 4 {
|
||||
let tds = adv.get_tap_dances();
|
||||
for i in 0..tds.row_count() {
|
||||
let td = tds.row_data(i).unwrap();
|
||||
if td.index == td_idx {
|
||||
let actions = td.actions;
|
||||
let mut a = actions.row_data(slot).unwrap();
|
||||
a.name = name.clone();
|
||||
a.code = code as i32;
|
||||
actions.set_row_data(slot, a);
|
||||
|
||||
let mut codes = [0u16; 4];
|
||||
for (j, code) in codes.iter_mut().enumerate().take(4.min(actions.row_count())) {
|
||||
*code = actions.row_data(j).unwrap().code as u16;
|
||||
}
|
||||
let payload = protocol::binary::td_set_payload(td_idx as u8, &codes);
|
||||
let serial = serial.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = ser.send_binary(protocol::binary::cmd::TD_SET, &payload);
|
||||
});
|
||||
w.global::<AppState>().set_status_text(
|
||||
SharedString::from(format!("TD{} slot {} = {}", td_idx, slot, name))
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"macro-step" => {
|
||||
let mut steps = macro_steps.borrow_mut();
|
||||
steps.push((code as u8, 0x00));
|
||||
drop(steps);
|
||||
refresh_macro_display();
|
||||
}
|
||||
_ => { apply_keycode(code); }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn setup_callbacks(window: &MainWindow, dispatch_keycode: Rc<dyn Fn(u16)>) {
|
||||
{
|
||||
let dispatch = dispatch_keycode.clone();
|
||||
window.global::<KeySelectorBridge>().on_select_keycode(move |code| {
|
||||
dispatch(code as u16);
|
||||
});
|
||||
}
|
||||
{
|
||||
let dispatch = dispatch_keycode.clone();
|
||||
window.global::<KeySelectorBridge>().on_apply_hex(move |hex_str| {
|
||||
if let Ok(code) = u16::from_str_radix(hex_str.trim(), 16) {
|
||||
dispatch(code);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let window_weak = window.as_weak();
|
||||
window.global::<KeySelectorBridge>().on_preview_hex(move |hex_str| {
|
||||
let preview = u16::from_str_radix(hex_str.trim(), 16)
|
||||
.map(keycode::decode_keycode)
|
||||
.unwrap_or_default();
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
w.global::<KeySelectorBridge>().set_hex_preview(SharedString::from(preview));
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let dispatch = dispatch_keycode.clone();
|
||||
window.global::<KeySelectorBridge>().on_apply_mt(move |mod_idx, key_idx| {
|
||||
let mod_nibble: u16 = match mod_idx {
|
||||
0 => 0x01, 1 => 0x02, 2 => 0x04, 3 => 0x08,
|
||||
4 => 0x10, 5 => 0x20, 6 => 0x40, 7 => 0x80,
|
||||
_ => 0x02,
|
||||
};
|
||||
let hid: u16 = match key_idx {
|
||||
0..=25 => 0x04 + key_idx as u16,
|
||||
26..=35 => 0x1E + (key_idx - 26) as u16,
|
||||
36 => 0x2C, 37 => 0x28, 38 => 0x29, 39 => 0x2B, 40 => 0x2A,
|
||||
_ => 0x04,
|
||||
};
|
||||
let code = 0x5000 | (mod_nibble << 8) | hid;
|
||||
dispatch(code);
|
||||
});
|
||||
}
|
||||
{
|
||||
let dispatch = dispatch_keycode.clone();
|
||||
window.global::<KeySelectorBridge>().on_apply_lt(move |layer_idx, key_idx| {
|
||||
let layer = (layer_idx as u16) & 0x0F;
|
||||
let hid: u16 = match key_idx {
|
||||
0 => 0x2C, 1 => 0x28, 2 => 0x29, 3 => 0x2A, 4 => 0x2B,
|
||||
5..=9 => 0x04 + (key_idx - 5) as u16,
|
||||
_ => 0x2C,
|
||||
};
|
||||
let code = 0x4000 | (layer << 8) | hid;
|
||||
dispatch(code);
|
||||
});
|
||||
}
|
||||
}
|
||||
110
src/keymap.rs
110
src/keymap.rs
|
|
@ -1,110 +0,0 @@
|
|||
use crate::context::{AppContext, BgMsg};
|
||||
use crate::protocol;
|
||||
use crate::{AppState, KeymapBridge, MainWindow};
|
||||
use slint::{ComponentHandle, Model, SharedString};
|
||||
|
||||
/// Wire up key selection, layer switch, layer rename, and heatmap toggle.
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
// --- Key selection callback ---
|
||||
{
|
||||
let window_weak = window.as_weak();
|
||||
window.global::<KeymapBridge>().on_select_key(move |key_index| {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let keycaps = w.global::<KeymapBridge>().get_keycaps();
|
||||
let idx = key_index as usize;
|
||||
if idx >= keycaps.row_count() { return; }
|
||||
for i in 0..keycaps.row_count() {
|
||||
let mut item = keycaps.row_data(i).unwrap();
|
||||
let should_select = i == idx;
|
||||
if item.selected != should_select {
|
||||
item.selected = should_select;
|
||||
keycaps.set_row_data(i, item);
|
||||
}
|
||||
}
|
||||
let bridge = w.global::<KeymapBridge>();
|
||||
bridge.set_selected_key_index(key_index);
|
||||
let item = keycaps.row_data(idx).unwrap();
|
||||
bridge.set_selected_key_label(item.label.clone());
|
||||
});
|
||||
}
|
||||
|
||||
// --- Layer switch callback ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let current_layer = ctx.current_layer.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<KeymapBridge>().on_switch_layer(move |layer_index| {
|
||||
let idx = layer_index as usize;
|
||||
current_layer.set(idx);
|
||||
|
||||
// Update active flag on the CURRENT model (not a captured stale ref)
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
let layers = w.global::<KeymapBridge>().get_layers();
|
||||
for i in 0..layers.row_count() {
|
||||
let mut item = layers.row_data(i).unwrap();
|
||||
let should_be_active = item.index == layer_index;
|
||||
if item.active != should_be_active {
|
||||
item.active = should_be_active;
|
||||
layers.set_row_data(i, item);
|
||||
}
|
||||
}
|
||||
w.global::<KeymapBridge>().set_active_layer(layer_index);
|
||||
w.global::<AppState>().set_status_text(SharedString::from(format!("Loading layer {}...", idx)));
|
||||
}
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match ser.get_keymap(idx as u8) {
|
||||
Ok(km) => { let _ = tx.send(BgMsg::Keymap(km)); }
|
||||
Err(e) => { let _ = tx.send(BgMsg::ConnectError(e)); }
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Layer rename callback ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<KeymapBridge>().on_rename_layer(move |layer_idx, new_name| {
|
||||
let payload = protocol::binary::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_binary(protocol::binary::cmd::SET_LAYOUT_NAME, &payload);
|
||||
if let Ok(names) = ser.get_layer_names() {
|
||||
let _ = tx.send(BgMsg::LayerNames(names));
|
||||
}
|
||||
});
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
w.global::<AppState>().set_status_text(
|
||||
SharedString::from(format!("Renamed layer {} → {}", layer_idx, new_name))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Heatmap toggle: auto-load data ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<KeymapBridge>().on_toggle_heatmap(move || {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Ok(resp) = ser.send_binary(protocol::binary::cmd::KEYSTATS_BIN, &[]) {
|
||||
let (data, max) = protocol::parsers::parse_keystats_binary(&resp.payload);
|
||||
let _ = tx.send(BgMsg::HeatmapData(data, max));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
113
src/layout.rs
113
src/layout.rs
|
|
@ -1,113 +0,0 @@
|
|||
use crate::context::AppContext;
|
||||
use crate::models;
|
||||
use crate::{LayoutBridge, MainWindow};
|
||||
use slint::{ComponentHandle, SharedString};
|
||||
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
setup_load_from_file(window);
|
||||
setup_load_from_keyboard(window, ctx);
|
||||
setup_export_json(window);
|
||||
}
|
||||
|
||||
// --- Layout preview: load from file ---
|
||||
fn setup_load_from_file(window: &MainWindow) {
|
||||
let window_weak = window.as_weak();
|
||||
window.global::<LayoutBridge>().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::<LayoutBridge>().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::<LayoutBridge>().set_file_path(SharedString::from(&path_str));
|
||||
models::populate_layout_preview(&w, &json);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Layout preview: load from keyboard ---
|
||||
fn setup_load_from_keyboard(window: &MainWindow, ctx: &AppContext) {
|
||||
let serial = ctx.serial.clone();
|
||||
let window_weak = window.as_weak();
|
||||
window.global::<LayoutBridge>().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::<LayoutBridge>().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() {
|
||||
models::populate_layout_preview(&w, &json);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Layout preview: export JSON ---
|
||||
fn setup_export_json(window: &MainWindow) {
|
||||
let window_weak = window.as_weak();
|
||||
window.global::<LayoutBridge>().on_export_json(move || {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let json = w.global::<LayoutBridge>().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::<LayoutBridge>().set_status(SharedString::from(msg));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
259
src/logic/binary_protocol.rs
Normal file
259
src/logic/binary_protocol.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/// KaSe Binary CDC Protocol v2
|
||||
/// Frame: KS(2) + cmd(1) + len(2 LE) + payload(N) + crc8(1)
|
||||
/// Response: KR(2) + cmd(1) + status(1) + len(2 LE) + payload(N) + crc8(1)
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub mod cmd {
|
||||
// System
|
||||
pub const VERSION: u8 = 0x01;
|
||||
pub const FEATURES: u8 = 0x02;
|
||||
pub const DFU: u8 = 0x03;
|
||||
pub const PING: u8 = 0x04;
|
||||
|
||||
// Keymap
|
||||
pub const SETLAYER: u8 = 0x10;
|
||||
pub const SETKEY: u8 = 0x11;
|
||||
pub const KEYMAP_CURRENT: u8 = 0x12;
|
||||
pub const KEYMAP_GET: u8 = 0x13;
|
||||
pub const LAYER_INDEX: u8 = 0x14;
|
||||
pub const LAYER_NAME: u8 = 0x15;
|
||||
|
||||
// Layout
|
||||
pub const SET_LAYOUT_NAME: u8 = 0x20;
|
||||
pub const LIST_LAYOUTS: u8 = 0x21;
|
||||
pub const GET_LAYOUT_JSON: u8 = 0x22;
|
||||
|
||||
// Macros
|
||||
pub const LIST_MACROS: u8 = 0x30;
|
||||
pub const MACRO_ADD: u8 = 0x31;
|
||||
pub const MACRO_ADD_SEQ: u8 = 0x32;
|
||||
pub const MACRO_DELETE: u8 = 0x33;
|
||||
|
||||
// Statistics
|
||||
pub const KEYSTATS_BIN: u8 = 0x40;
|
||||
pub const KEYSTATS_RESET: u8 = 0x42;
|
||||
pub const BIGRAMS_BIN: u8 = 0x43;
|
||||
pub const BIGRAMS_RESET: u8 = 0x45;
|
||||
|
||||
// Tap Dance
|
||||
pub const TD_SET: u8 = 0x50;
|
||||
pub const TD_LIST: u8 = 0x51;
|
||||
pub const TD_DELETE: u8 = 0x52;
|
||||
|
||||
// Combos
|
||||
pub const COMBO_SET: u8 = 0x60;
|
||||
pub const COMBO_LIST: u8 = 0x61;
|
||||
pub const COMBO_DELETE: u8 = 0x62;
|
||||
|
||||
// Leader
|
||||
pub const LEADER_SET: u8 = 0x70;
|
||||
pub const LEADER_LIST: u8 = 0x71;
|
||||
pub const LEADER_DELETE: u8 = 0x72;
|
||||
|
||||
// Bluetooth
|
||||
pub const BT_QUERY: u8 = 0x80;
|
||||
pub const BT_SWITCH: u8 = 0x81;
|
||||
pub const BT_PAIR: u8 = 0x82;
|
||||
pub const BT_DISCONNECT: u8 = 0x83;
|
||||
pub const BT_NEXT: u8 = 0x84;
|
||||
pub const BT_PREV: u8 = 0x85;
|
||||
|
||||
// Features
|
||||
pub const AUTOSHIFT_TOGGLE: u8 = 0x90;
|
||||
pub const KO_SET: u8 = 0x91;
|
||||
pub const KO_LIST: u8 = 0x92;
|
||||
pub const KO_DELETE: u8 = 0x93;
|
||||
pub const WPM_QUERY: u8 = 0x94;
|
||||
pub const TRILAYER_SET: u8 = 0x94;
|
||||
|
||||
// Tamagotchi
|
||||
pub const TAMA_QUERY: u8 = 0xA0;
|
||||
pub const TAMA_ENABLE: u8 = 0xA1;
|
||||
pub const TAMA_DISABLE: u8 = 0xA2;
|
||||
pub const TAMA_FEED: u8 = 0xA3;
|
||||
pub const TAMA_PLAY: u8 = 0xA4;
|
||||
pub const TAMA_SLEEP: u8 = 0xA5;
|
||||
pub const TAMA_MEDICINE: u8 = 0xA6;
|
||||
pub const TAMA_SAVE: u8 = 0xA7;
|
||||
|
||||
// OTA
|
||||
pub const OTA_START: u8 = 0xF0;
|
||||
pub const OTA_DATA: u8 = 0xF1;
|
||||
pub const OTA_ABORT: u8 = 0xF2;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub mod status {
|
||||
pub const OK: u8 = 0x00;
|
||||
pub const ERR_UNKNOWN: u8 = 0x01;
|
||||
pub const ERR_CRC: u8 = 0x02;
|
||||
pub const ERR_INVALID: u8 = 0x03;
|
||||
pub const ERR_RANGE: u8 = 0x04;
|
||||
pub const ERR_BUSY: u8 = 0x05;
|
||||
pub const ERR_OVERFLOW: u8 = 0x06;
|
||||
}
|
||||
|
||||
/// CRC-8/MAXIM (polynomial 0x31, init 0x00)
|
||||
pub fn crc8(data: &[u8]) -> u8 {
|
||||
let mut crc: u8 = 0x00;
|
||||
for &b in data {
|
||||
crc ^= b;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x80 != 0 {
|
||||
(crc << 1) ^ 0x31
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Build a KS request frame.
|
||||
pub fn ks_frame(cmd_id: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let len = payload.len() as u16;
|
||||
let mut frame = Vec::with_capacity(6 + payload.len());
|
||||
frame.push(0x4B); // 'K'
|
||||
frame.push(0x53); // 'S'
|
||||
frame.push(cmd_id);
|
||||
frame.push((len & 0xFF) as u8);
|
||||
frame.push((len >> 8) as u8);
|
||||
frame.extend_from_slice(payload);
|
||||
frame.push(crc8(payload));
|
||||
frame
|
||||
}
|
||||
|
||||
/// Build MACRO_ADD_SEQ payload: [slot][name_len][name...][step_count][{kc,mod}...]
|
||||
pub fn macro_add_seq_payload(slot: u8, name: &str, steps_hex: &str) -> Vec<u8> {
|
||||
let name_bytes = name.as_bytes();
|
||||
let name_len = name_bytes.len().min(255) as u8;
|
||||
|
||||
// Parse hex steps "06:01,FF:0A,19:01" into (kc, mod) pairs
|
||||
let mut step_pairs: Vec<(u8, u8)> = Vec::new();
|
||||
if !steps_hex.is_empty() {
|
||||
for part in steps_hex.split(',') {
|
||||
let trimmed = part.trim();
|
||||
let kv: Vec<&str> = trimmed.split(':').collect();
|
||||
let has_two = kv.len() == 2;
|
||||
if !has_two {
|
||||
continue;
|
||||
}
|
||||
let kc = u8::from_str_radix(kv[0].trim(), 16).unwrap_or(0);
|
||||
let md = u8::from_str_radix(kv[1].trim(), 16).unwrap_or(0);
|
||||
step_pairs.push((kc, md));
|
||||
}
|
||||
}
|
||||
let step_count = step_pairs.len().min(255) as u8;
|
||||
|
||||
let mut payload = Vec::new();
|
||||
payload.push(slot);
|
||||
payload.push(name_len);
|
||||
payload.extend_from_slice(&name_bytes[..name_len as usize]);
|
||||
payload.push(step_count);
|
||||
for (kc, md) in &step_pairs {
|
||||
payload.push(*kc);
|
||||
payload.push(*md);
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
/// Build MACRO_DELETE payload: [slot]
|
||||
pub fn macro_delete_payload(slot: u8) -> Vec<u8> {
|
||||
vec![slot]
|
||||
}
|
||||
|
||||
/// Build COMBO_SET payload: [index][r1][c1][r2][c2][result]
|
||||
pub fn combo_set_payload(index: u8, r1: u8, c1: u8, r2: u8, c2: u8, result: u8) -> Vec<u8> {
|
||||
vec![index, r1, c1, r2, c2, result]
|
||||
}
|
||||
|
||||
/// Build TD_SET payload: [index][a1][a2][a3][a4]
|
||||
pub fn td_set_payload(index: u8, actions: &[u16; 4]) -> Vec<u8> {
|
||||
vec![index, actions[0] as u8, actions[1] as u8, actions[2] as u8, actions[3] as u8]
|
||||
}
|
||||
|
||||
/// Build KO_SET payload: [index][trigger_key][trigger_mod][result_key][result_mod]
|
||||
pub fn ko_set_payload(index: u8, trig_key: u8, trig_mod: u8, res_key: u8, res_mod: u8) -> Vec<u8> {
|
||||
vec![index, trig_key, trig_mod, res_key, res_mod]
|
||||
}
|
||||
|
||||
/// Build LEADER_SET payload: [index][seq_len][seq...][result][result_mod]
|
||||
pub fn leader_set_payload(index: u8, sequence: &[u8], result: u8, result_mod: u8) -> Vec<u8> {
|
||||
let seq_len = sequence.len().min(4) as u8;
|
||||
let mut payload = Vec::with_capacity(4 + sequence.len());
|
||||
payload.push(index);
|
||||
payload.push(seq_len);
|
||||
payload.extend_from_slice(&sequence[..seq_len as usize]);
|
||||
payload.push(result);
|
||||
payload.push(result_mod);
|
||||
payload
|
||||
}
|
||||
|
||||
/// Parsed KR response.
|
||||
#[derive(Debug)]
|
||||
pub struct KrResponse {
|
||||
#[allow(dead_code)]
|
||||
pub cmd: u8,
|
||||
pub status: u8,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl KrResponse {
|
||||
pub fn is_ok(&self) -> bool {
|
||||
self.status == status::OK
|
||||
}
|
||||
|
||||
pub fn status_name(&self) -> &str {
|
||||
match self.status {
|
||||
status::OK => "OK",
|
||||
status::ERR_UNKNOWN => "ERR_UNKNOWN",
|
||||
status::ERR_CRC => "ERR_CRC",
|
||||
status::ERR_INVALID => "ERR_INVALID",
|
||||
status::ERR_RANGE => "ERR_RANGE",
|
||||
status::ERR_BUSY => "ERR_BUSY",
|
||||
status::ERR_OVERFLOW => "ERR_OVERFLOW",
|
||||
_ => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a KR response from raw bytes. Returns (response, bytes_consumed).
|
||||
pub fn parse_kr(data: &[u8]) -> Result<(KrResponse, usize), String> {
|
||||
// Find KR magic
|
||||
let pos = data
|
||||
.windows(2)
|
||||
.position(|w| w[0] == 0x4B && w[1] == 0x52)
|
||||
.ok_or("No KR header found")?;
|
||||
|
||||
if data.len() < pos + 7 {
|
||||
return Err("Response too short".into());
|
||||
}
|
||||
|
||||
let cmd = data[pos + 2];
|
||||
let status = data[pos + 3];
|
||||
let plen = data[pos + 4] as u16 | ((data[pos + 5] as u16) << 8);
|
||||
let payload_start = pos + 6;
|
||||
let payload_end = payload_start + plen as usize;
|
||||
|
||||
if data.len() < payload_end + 1 {
|
||||
return Err(format!(
|
||||
"Incomplete response: need {} bytes, got {}",
|
||||
payload_end + 1,
|
||||
data.len()
|
||||
));
|
||||
}
|
||||
|
||||
let payload = data[payload_start..payload_end].to_vec();
|
||||
let expected_crc = data[payload_end];
|
||||
let actual_crc = crc8(&payload);
|
||||
|
||||
if expected_crc != actual_crc {
|
||||
return Err(format!(
|
||||
"CRC mismatch: expected 0x{:02X}, got 0x{:02X}",
|
||||
expected_crc, actual_crc
|
||||
));
|
||||
}
|
||||
|
||||
let consumed = payload_end + 1 - pos;
|
||||
Ok((KrResponse { cmd, status, payload }, consumed))
|
||||
}
|
||||
|
|
@ -643,17 +643,23 @@ pub fn flash_firmware(
|
|||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 8: Erase otadata when flashing factory //
|
||||
// Step 8: FLASH_END //
|
||||
// //
|
||||
// We pass reboot=false here so the chip stays in bootloader mode for //
|
||||
// the MD5 verification step that follows. A hard reset is done after. //
|
||||
// ------------------------------------------------------------------ //
|
||||
send_progress(0.92, "Finalizing write...".into());
|
||||
flash_end(&mut port, false)?;
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 8b: Erase otadata partition (0xF000, 8KB) when flashing factory //
|
||||
// //
|
||||
// If we just wrote to the factory partition, the bootloader might //
|
||||
// still have otadata pointing to ota_0. We erase otadata to force //
|
||||
// the bootloader to fall back to factory on next boot. //
|
||||
// //
|
||||
// The ROM accepts consecutive flash_begin calls without flash_end in //
|
||||
// between — no need to end+re-sync between the two flash sequences. //
|
||||
// ------------------------------------------------------------------ //
|
||||
if offset == 0x20000 {
|
||||
send_progress(0.92, "Erasing otadata (force factory boot)...".into());
|
||||
send_progress(0.93, "Erasing otadata (force factory boot)...".into());
|
||||
const OTADATA_OFFSET: u32 = 0xF000;
|
||||
const OTADATA_SIZE: u32 = 0x2000; // 8 KB
|
||||
flash_begin(&mut port, OTADATA_OFFSET, OTADATA_SIZE, FLASH_BLOCK_SIZE)?;
|
||||
|
|
@ -662,14 +668,9 @@ pub fn flash_firmware(
|
|||
for i in 0..otadata_blocks {
|
||||
flash_data(&mut port, i, &empty_block)?;
|
||||
}
|
||||
flash_end(&mut port, false)?;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 9: FLASH_END — stay in bootloader for MD5 verification //
|
||||
// ------------------------------------------------------------------ //
|
||||
send_progress(0.93, "Finalizing write...".into());
|
||||
flash_end(&mut port, false)?;
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Step 9: MD5 post-write verification //
|
||||
// //
|
||||
390
src/logic/keycode.rs
Normal file
390
src/logic/keycode.rs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
/// Decode a raw 16-bit keycode into a human-readable string.
|
||||
///
|
||||
/// Covers all KaSe firmware keycode ranges: HID basic keys, layer switches,
|
||||
/// macros, Bluetooth, one-shot, mod-tap, layer-tap, tap-dance, and more.
|
||||
pub fn decode_keycode(raw: u16) -> String {
|
||||
// --- HID basic keycodes 0x00..=0xE7 ---
|
||||
if raw <= 0x00E7 {
|
||||
return hid_key_name(raw as u8);
|
||||
}
|
||||
|
||||
// --- MO (Momentary Layer): 0x0100..=0x0A00, low byte == 0 ---
|
||||
if raw >= 0x0100 && raw <= 0x0A00 && (raw & 0xFF) == 0 {
|
||||
let layer = (raw >> 8) - 1;
|
||||
return format!("MO {layer}");
|
||||
}
|
||||
|
||||
// --- TO (Toggle Layer): 0x0B00..=0x1400, low byte == 0 ---
|
||||
if raw >= 0x0B00 && raw <= 0x1400 && (raw & 0xFF) == 0 {
|
||||
let layer = (raw >> 8) - 0x0B;
|
||||
return format!("TO {layer}");
|
||||
}
|
||||
|
||||
// --- MACRO: 0x1500..=0x2800, low byte == 0 ---
|
||||
if raw >= 0x1500 && raw <= 0x2800 && (raw & 0xFF) == 0 {
|
||||
let idx = (raw >> 8) - 0x14;
|
||||
return format!("M{idx}");
|
||||
}
|
||||
|
||||
// --- BT keycodes ---
|
||||
match raw {
|
||||
0x2900 => return "BT Next".into(),
|
||||
0x2A00 => return "BT Prev".into(),
|
||||
0x2B00 => return "BT Pair".into(),
|
||||
0x2C00 => return "BT Disc".into(),
|
||||
0x2E00 => return "USB/BT".into(),
|
||||
0x2F00 => return "BT On/Off".into(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// --- OSM (One-Shot Mod): 0x3000..=0x30FF ---
|
||||
if raw >= 0x3000 && raw <= 0x30FF {
|
||||
let mods = (raw & 0xFF) as u8;
|
||||
return format!("OSM {}", mod_name(mods));
|
||||
}
|
||||
|
||||
// --- OSL (One-Shot Layer): 0x3100..=0x310F ---
|
||||
if raw >= 0x3100 && raw <= 0x310F {
|
||||
let layer = raw & 0x0F;
|
||||
return format!("OSL {layer}");
|
||||
}
|
||||
|
||||
// --- Fixed special codes ---
|
||||
match raw {
|
||||
0x3200 => return "Caps Word".into(),
|
||||
0x3300 => return "Repeat".into(),
|
||||
0x3400 => return "Leader".into(),
|
||||
0x3500 => return "Feed".into(),
|
||||
0x3600 => return "Play".into(),
|
||||
0x3700 => return "Sleep".into(),
|
||||
0x3800 => return "Meds".into(),
|
||||
0x3900 => return "GEsc".into(),
|
||||
0x3A00 => return "Layer Lock".into(),
|
||||
0x3C00 => return "AS Toggle".into(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// --- KO (Key Override) slots: 0x3D00..=0x3DFF ---
|
||||
if raw >= 0x3D00 && raw <= 0x3DFF {
|
||||
let slot = raw & 0xFF;
|
||||
return format!("KO {slot}");
|
||||
}
|
||||
|
||||
// --- LT (Layer-Tap): 0x4000..=0x4FFF ---
|
||||
// layout: 0x4LKK where L = layer (0..F), KK = HID keycode
|
||||
if raw >= 0x4000 && raw <= 0x4FFF {
|
||||
let layer = (raw >> 8) & 0x0F;
|
||||
let kc = (raw & 0xFF) as u8;
|
||||
return format!("LT {} {}", layer, hid_key_name(kc));
|
||||
}
|
||||
|
||||
// --- MT (Mod-Tap): 0x5000..=0x5FFF ---
|
||||
// layout: 0x5MKK where M = mod nibble (4 bits), KK = HID keycode
|
||||
if raw >= 0x5000 && raw <= 0x5FFF {
|
||||
let mods = ((raw >> 8) & 0x0F) as u8;
|
||||
let kc = (raw & 0xFF) as u8;
|
||||
return format!("MT {} {}", mod_name(mods), hid_key_name(kc));
|
||||
}
|
||||
|
||||
// --- TD (Tap Dance): 0x6000..=0x6FFF ---
|
||||
if raw >= 0x6000 && raw <= 0x6FFF {
|
||||
let index = (raw >> 8) & 0x0F;
|
||||
return format!("TD {index}");
|
||||
}
|
||||
|
||||
// --- Unknown ---
|
||||
format!("0x{raw:04X}")
|
||||
}
|
||||
|
||||
/// Decode a modifier bitmask into a human-readable string.
|
||||
///
|
||||
/// Bits: 0x01=Ctrl, 0x02=Shift, 0x04=Alt, 0x08=GUI,
|
||||
/// 0x10=RCtrl, 0x20=RShift, 0x40=RAlt, 0x80=RGUI.
|
||||
/// Multiple modifiers are joined with "+".
|
||||
pub fn mod_name(mod_mask: u8) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if mod_mask & 0x01 != 0 { parts.push("Ctrl"); }
|
||||
if mod_mask & 0x02 != 0 { parts.push("Shift"); }
|
||||
if mod_mask & 0x04 != 0 { parts.push("Alt"); }
|
||||
if mod_mask & 0x08 != 0 { parts.push("GUI"); }
|
||||
if mod_mask & 0x10 != 0 { parts.push("RCtrl"); }
|
||||
if mod_mask & 0x20 != 0 { parts.push("RShift"); }
|
||||
if mod_mask & 0x40 != 0 { parts.push("RAlt"); }
|
||||
if mod_mask & 0x80 != 0 { parts.push("RGUI"); }
|
||||
if parts.is_empty() {
|
||||
format!("0x{mod_mask:02X}")
|
||||
} else {
|
||||
parts.join("+")
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a single HID usage code (0x00..=0xE7) to a short readable name.
|
||||
pub fn hid_key_name(code: u8) -> String {
|
||||
match code {
|
||||
// No key / transparent
|
||||
0x00 => "None",
|
||||
// 0x01 = ErrorRollOver, 0x02 = POSTFail, 0x03 = ErrorUndefined (not user-facing)
|
||||
0x01 => "ErrRollOver",
|
||||
0x02 => "POSTFail",
|
||||
0x03 => "ErrUndef",
|
||||
|
||||
// Letters
|
||||
0x04 => "A",
|
||||
0x05 => "B",
|
||||
0x06 => "C",
|
||||
0x07 => "D",
|
||||
0x08 => "E",
|
||||
0x09 => "F",
|
||||
0x0A => "G",
|
||||
0x0B => "H",
|
||||
0x0C => "I",
|
||||
0x0D => "J",
|
||||
0x0E => "K",
|
||||
0x0F => "L",
|
||||
0x10 => "M",
|
||||
0x11 => "N",
|
||||
0x12 => "O",
|
||||
0x13 => "P",
|
||||
0x14 => "Q",
|
||||
0x15 => "R",
|
||||
0x16 => "S",
|
||||
0x17 => "T",
|
||||
0x18 => "U",
|
||||
0x19 => "V",
|
||||
0x1A => "W",
|
||||
0x1B => "X",
|
||||
0x1C => "Y",
|
||||
0x1D => "Z",
|
||||
|
||||
// Number row
|
||||
0x1E => "1",
|
||||
0x1F => "2",
|
||||
0x20 => "3",
|
||||
0x21 => "4",
|
||||
0x22 => "5",
|
||||
0x23 => "6",
|
||||
0x24 => "7",
|
||||
0x25 => "8",
|
||||
0x26 => "9",
|
||||
0x27 => "0",
|
||||
|
||||
// Common control keys
|
||||
0x28 => "Enter",
|
||||
0x29 => "Esc",
|
||||
0x2A => "Backspace",
|
||||
0x2B => "Tab",
|
||||
0x2C => "Space",
|
||||
|
||||
// Punctuation / symbols
|
||||
0x2D => "-",
|
||||
0x2E => "=",
|
||||
0x2F => "[",
|
||||
0x30 => "]",
|
||||
0x31 => "\\",
|
||||
0x32 => "Europe1",
|
||||
0x33 => ";",
|
||||
0x34 => "'",
|
||||
0x35 => "`",
|
||||
0x36 => ",",
|
||||
0x37 => ".",
|
||||
0x38 => "/",
|
||||
|
||||
// Caps Lock
|
||||
0x39 => "Caps Lock",
|
||||
|
||||
// Function keys
|
||||
0x3A => "F1",
|
||||
0x3B => "F2",
|
||||
0x3C => "F3",
|
||||
0x3D => "F4",
|
||||
0x3E => "F5",
|
||||
0x3F => "F6",
|
||||
0x40 => "F7",
|
||||
0x41 => "F8",
|
||||
0x42 => "F9",
|
||||
0x43 => "F10",
|
||||
0x44 => "F11",
|
||||
0x45 => "F12",
|
||||
|
||||
// Navigation / editing cluster
|
||||
0x46 => "PrtSc",
|
||||
0x47 => "ScrLk",
|
||||
0x48 => "Pause",
|
||||
0x49 => "Ins",
|
||||
0x4A => "Home",
|
||||
0x4B => "PgUp",
|
||||
0x4C => "Del",
|
||||
0x4D => "End",
|
||||
0x4E => "PgDn",
|
||||
|
||||
// Arrow keys
|
||||
0x4F => "Right",
|
||||
0x50 => "Left",
|
||||
0x51 => "Down",
|
||||
0x52 => "Up",
|
||||
|
||||
// Keypad
|
||||
0x53 => "NumLk",
|
||||
0x54 => "Num /",
|
||||
0x55 => "Num *",
|
||||
0x56 => "Num -",
|
||||
0x57 => "Num +",
|
||||
0x58 => "Num Enter",
|
||||
0x59 => "Num 1",
|
||||
0x5A => "Num 2",
|
||||
0x5B => "Num 3",
|
||||
0x5C => "Num 4",
|
||||
0x5D => "Num 5",
|
||||
0x5E => "Num 6",
|
||||
0x5F => "Num 7",
|
||||
0x60 => "Num 8",
|
||||
0x61 => "Num 9",
|
||||
0x62 => "Num 0",
|
||||
0x63 => "Num .",
|
||||
0x64 => "Europe2",
|
||||
0x65 => "Menu",
|
||||
0x66 => "Power",
|
||||
0x67 => "Num =",
|
||||
|
||||
// F13-F24
|
||||
0x68 => "F13",
|
||||
0x69 => "F14",
|
||||
0x6A => "F15",
|
||||
0x6B => "F16",
|
||||
0x6C => "F17",
|
||||
0x6D => "F18",
|
||||
0x6E => "F19",
|
||||
0x6F => "F20",
|
||||
0x70 => "F21",
|
||||
0x71 => "F22",
|
||||
0x72 => "F23",
|
||||
0x73 => "F24",
|
||||
|
||||
// Misc system keys
|
||||
0x74 => "Execute",
|
||||
0x75 => "Help",
|
||||
0x76 => "Menu2",
|
||||
0x77 => "Select",
|
||||
0x78 => "Stop",
|
||||
0x79 => "Again",
|
||||
0x7A => "Undo",
|
||||
0x7B => "Cut",
|
||||
0x7C => "Copy",
|
||||
0x7D => "Paste",
|
||||
0x7E => "Find",
|
||||
0x7F => "Mute",
|
||||
0x80 => "Vol Up",
|
||||
0x81 => "Vol Down",
|
||||
|
||||
// Locking keys
|
||||
0x82 => "Lock Caps",
|
||||
0x83 => "Lock Num",
|
||||
0x84 => "Lock Scroll",
|
||||
|
||||
// Keypad extras
|
||||
0x85 => "Num ,",
|
||||
0x86 => "Num =2",
|
||||
|
||||
// International / Kanji
|
||||
0x87 => "Kanji1",
|
||||
0x88 => "Kanji2",
|
||||
0x89 => "Kanji3",
|
||||
0x8A => "Kanji4",
|
||||
0x8B => "Kanji5",
|
||||
0x8C => "Kanji6",
|
||||
0x8D => "Kanji7",
|
||||
0x8E => "Kanji8",
|
||||
0x8F => "Kanji9",
|
||||
|
||||
// Language keys
|
||||
0x90 => "Lang1",
|
||||
0x91 => "Lang2",
|
||||
0x92 => "Lang3",
|
||||
0x93 => "Lang4",
|
||||
0x94 => "Lang5",
|
||||
0x95 => "Lang6",
|
||||
0x96 => "Lang7",
|
||||
0x97 => "Lang8",
|
||||
0x98 => "Lang9",
|
||||
|
||||
// Rare system keys
|
||||
0x99 => "Alt Erase",
|
||||
0x9A => "SysReq",
|
||||
0x9B => "Cancel",
|
||||
0x9C => "Clear",
|
||||
0x9D => "Prior",
|
||||
0x9E => "Return",
|
||||
0x9F => "Separator",
|
||||
0xA0 => "Out",
|
||||
0xA1 => "Oper",
|
||||
0xA2 => "Clear Again",
|
||||
0xA3 => "CrSel",
|
||||
0xA4 => "ExSel",
|
||||
|
||||
// 0xA5..=0xAF reserved / not defined in standard HID tables
|
||||
|
||||
// Extended keypad
|
||||
0xB0 => "Num 00",
|
||||
0xB1 => "Num 000",
|
||||
0xB2 => "Thousands Sep",
|
||||
0xB3 => "Decimal Sep",
|
||||
0xB4 => "Currency",
|
||||
0xB5 => "Currency Sub",
|
||||
0xB6 => "Num (",
|
||||
0xB7 => "Num )",
|
||||
0xB8 => "Num {",
|
||||
0xB9 => "Num }",
|
||||
0xBA => "Num Tab",
|
||||
0xBB => "Num Bksp",
|
||||
0xBC => "Num A",
|
||||
0xBD => "Num B",
|
||||
0xBE => "Num C",
|
||||
0xBF => "Num D",
|
||||
0xC0 => "Num E",
|
||||
0xC1 => "Num F",
|
||||
0xC2 => "Num XOR",
|
||||
0xC3 => "Num ^",
|
||||
0xC4 => "Num %",
|
||||
0xC5 => "Num <",
|
||||
0xC6 => "Num >",
|
||||
0xC7 => "Num &",
|
||||
0xC8 => "Num &&",
|
||||
0xC9 => "Num |",
|
||||
0xCA => "Num ||",
|
||||
0xCB => "Num :",
|
||||
0xCC => "Num #",
|
||||
0xCD => "Num Space",
|
||||
0xCE => "Num @",
|
||||
0xCF => "Num !",
|
||||
0xD0 => "Num M Store",
|
||||
0xD1 => "Num M Recall",
|
||||
0xD2 => "Num M Clear",
|
||||
0xD3 => "Num M+",
|
||||
0xD4 => "Num M-",
|
||||
0xD5 => "Num M*",
|
||||
0xD6 => "Num M/",
|
||||
0xD7 => "Num +/-",
|
||||
0xD8 => "Num Clear",
|
||||
0xD9 => "Num ClrEntry",
|
||||
0xDA => "Num Binary",
|
||||
0xDB => "Num Octal",
|
||||
0xDC => "Num Decimal",
|
||||
0xDD => "Num Hex",
|
||||
|
||||
// 0xDE..=0xDF reserved
|
||||
|
||||
// Modifier keys
|
||||
0xE0 => "LCtrl",
|
||||
0xE1 => "LShift",
|
||||
0xE2 => "LAlt",
|
||||
0xE3 => "LGUI",
|
||||
0xE4 => "RCtrl",
|
||||
0xE5 => "RShift",
|
||||
0xE6 => "RAlt",
|
||||
0xE7 => "RGUI",
|
||||
|
||||
// Anything else in 0x00..=0xFF not covered above
|
||||
_ => return format!("0x{code:02X}"),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
286
src/logic/layout.rs
Normal file
286
src/logic/layout.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
use serde_json::Value;
|
||||
|
||||
/// A keycap with computed absolute position.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct KeycapPos {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
pub angle: f32, // degrees
|
||||
}
|
||||
|
||||
const KEY_SIZE: f32 = 50.0;
|
||||
const KEY_GAP: f32 = 4.0;
|
||||
|
||||
/// Parse a layout JSON string into absolute key positions.
|
||||
pub fn parse_json(json: &str) -> Result<Vec<KeycapPos>, String> {
|
||||
let val: Value = serde_json::from_str(json)
|
||||
.map_err(|e| format!("Invalid layout JSON: {}", e))?;
|
||||
let mut keys = Vec::new();
|
||||
walk(&val, 0.0, 0.0, 0.0, &mut keys);
|
||||
if keys.is_empty() {
|
||||
return Err("No keys found in layout".into());
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Default layout embedded at compile time.
|
||||
pub fn default_layout() -> Vec<KeycapPos> {
|
||||
let json = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/default.json"));
|
||||
parse_json(json).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn walk(node: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match node.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
for (key, val) in obj {
|
||||
let key_str = key.as_str();
|
||||
match key_str {
|
||||
"Group" => walk_group(val, ox, oy, parent_angle, out),
|
||||
"Line" => walk_line(val, ox, oy, parent_angle, out),
|
||||
"Keycap" => walk_keycap(val, ox, oy, parent_angle, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_margin(val: &Value) -> (f32, f32, f32, f32) {
|
||||
let as_str = val.as_str();
|
||||
if let Some(s) = as_str {
|
||||
let split = s.split(',');
|
||||
let parts: Vec<f32> = split
|
||||
.filter_map(|p| {
|
||||
let trimmed = p.trim();
|
||||
let parsed = trimmed.parse().ok();
|
||||
parsed
|
||||
})
|
||||
.collect();
|
||||
let has_four_parts = parts.len() == 4;
|
||||
if has_four_parts {
|
||||
return (parts[0], parts[1], parts[2], parts[3]);
|
||||
}
|
||||
}
|
||||
(0.0, 0.0, 0.0, 0.0)
|
||||
}
|
||||
|
||||
fn parse_angle(val: &Value) -> f32 {
|
||||
let rotate_transform = val.get("RotateTransform");
|
||||
let angle_val = rotate_transform
|
||||
.and_then(|rt| rt.get("Angle"));
|
||||
let angle_f64 = angle_val
|
||||
.and_then(|a| a.as_f64());
|
||||
let angle = angle_f64.unwrap_or(0.0) as f32;
|
||||
angle
|
||||
}
|
||||
|
||||
fn walk_group(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let gx = ox + ml;
|
||||
let gy = oy + mt;
|
||||
|
||||
let children_val = obj.get("Children");
|
||||
let children_array = children_val
|
||||
.and_then(|c| c.as_array());
|
||||
if let Some(children) = children_array {
|
||||
let combined_angle = parent_angle + angle;
|
||||
for child in children {
|
||||
walk(child, gx, gy, combined_angle, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_line(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
let total_angle = parent_angle + angle;
|
||||
|
||||
let orientation_val = obj.get("Orientation");
|
||||
let orientation_str = orientation_val
|
||||
.and_then(|o| o.as_str())
|
||||
.unwrap_or("Vertical");
|
||||
let horiz = orientation_str == "Horizontal";
|
||||
|
||||
let lx = ox + ml;
|
||||
let ly = oy + mt;
|
||||
|
||||
let rad = total_angle.to_radians();
|
||||
let cos_a = rad.cos();
|
||||
let sin_a = rad.sin();
|
||||
|
||||
let mut cursor = 0.0f32;
|
||||
|
||||
let children_val = obj.get("Children");
|
||||
let children_array = children_val
|
||||
.and_then(|c| c.as_array());
|
||||
if let Some(children) = children_array {
|
||||
for child in children {
|
||||
let (cx, cy) = if horiz {
|
||||
let x = lx + cursor * cos_a;
|
||||
let y = ly + cursor * sin_a;
|
||||
(x, y)
|
||||
} else {
|
||||
let x = lx - cursor * sin_a;
|
||||
let y = ly + cursor * cos_a;
|
||||
(x, y)
|
||||
};
|
||||
|
||||
let child_size = measure(child, horiz);
|
||||
walk(child, cx, cy, total_angle, out);
|
||||
cursor += child_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure a child's extent along the parent's main axis.
|
||||
fn measure(node: &Value, horiz: bool) -> f32 {
|
||||
let obj = match node.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
|
||||
for (key, val) in obj {
|
||||
let key_str = key.as_str();
|
||||
match key_str {
|
||||
"Keycap" => {
|
||||
let width_val = val.get("Width");
|
||||
let width_f64 = width_val
|
||||
.and_then(|v| v.as_f64());
|
||||
let w = width_f64.unwrap_or(KEY_SIZE as f64) as f32;
|
||||
let extent = if horiz {
|
||||
w + KEY_GAP
|
||||
} else {
|
||||
KEY_SIZE + KEY_GAP
|
||||
};
|
||||
return extent;
|
||||
}
|
||||
"Line" => {
|
||||
let sub = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
let sub_orientation = sub.get("Orientation");
|
||||
let sub_orient_str = sub_orientation
|
||||
.and_then(|o| o.as_str())
|
||||
.unwrap_or("Vertical");
|
||||
let sub_horiz = sub_orient_str == "Horizontal";
|
||||
|
||||
let sub_children_val = sub.get("Children");
|
||||
let sub_children_array = sub_children_val
|
||||
.and_then(|c| c.as_array());
|
||||
let children = sub_children_array
|
||||
.map(|a| a.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
let same_direction = sub_horiz == horiz;
|
||||
let content: f32 = if same_direction {
|
||||
// Same direction: sum
|
||||
children
|
||||
.iter()
|
||||
.map(|c| measure(c, sub_horiz))
|
||||
.sum()
|
||||
} else {
|
||||
// Cross direction: max
|
||||
children
|
||||
.iter()
|
||||
.map(|c| measure(c, horiz))
|
||||
.fold(0.0f32, f32::max)
|
||||
};
|
||||
|
||||
return content;
|
||||
}
|
||||
"Group" => {
|
||||
let sub = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return 0.0,
|
||||
};
|
||||
let sub_children_val = sub.get("Children");
|
||||
let sub_children_array = sub_children_val
|
||||
.and_then(|c| c.as_array());
|
||||
let children = sub_children_array
|
||||
.map(|a| a.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let max_extent = children
|
||||
.iter()
|
||||
.map(|c| measure(c, horiz))
|
||||
.fold(0.0f32, f32::max);
|
||||
return max_extent;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
0.0
|
||||
}
|
||||
|
||||
fn walk_keycap(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
|
||||
let obj = match val.as_object() {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let col_val = obj.get("Column");
|
||||
let col_u64 = col_val
|
||||
.and_then(|v| v.as_u64());
|
||||
let col = col_u64.unwrap_or(0) as usize;
|
||||
|
||||
let row_val = obj.get("Row");
|
||||
let row_u64 = row_val
|
||||
.and_then(|v| v.as_u64());
|
||||
let row = row_u64.unwrap_or(0) as usize;
|
||||
|
||||
let width_val = obj.get("Width");
|
||||
let width_f64 = width_val
|
||||
.and_then(|v| v.as_f64());
|
||||
let w = width_f64.unwrap_or(KEY_SIZE as f64) as f32;
|
||||
|
||||
let margin_val = obj.get("Margin");
|
||||
let (ml, mt, _, _) = margin_val
|
||||
.map(parse_margin)
|
||||
.unwrap_or_default();
|
||||
|
||||
let transform_val = obj.get("RenderTransform");
|
||||
let angle = transform_val
|
||||
.map(parse_angle)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let total_angle = parent_angle + angle;
|
||||
|
||||
out.push(KeycapPos {
|
||||
row,
|
||||
col,
|
||||
x: ox + ml,
|
||||
y: oy + mt,
|
||||
w,
|
||||
h: KEY_SIZE,
|
||||
angle: total_angle,
|
||||
});
|
||||
}
|
||||
339
src/logic/layout_remap.rs
Normal file
339
src/logic/layout_remap.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
/// Remaps HID key names to their visual representation based on the user's
|
||||
/// keyboard layout (language). Ported from the C# `KeyConverter.LayoutOverrides`.
|
||||
|
||||
// Display implementation for the keyboard layout picker in settings.
|
||||
impl std::fmt::Display for KeyboardLayout {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.name())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum KeyboardLayout {
|
||||
Qwerty,
|
||||
Azerty,
|
||||
Qwertz,
|
||||
Dvorak,
|
||||
Colemak,
|
||||
Bepo,
|
||||
QwertyEs,
|
||||
QwertyPt,
|
||||
QwertyIt,
|
||||
QwertyNordic,
|
||||
QwertyBr,
|
||||
QwertyTr,
|
||||
QwertyUk,
|
||||
}
|
||||
|
||||
impl KeyboardLayout {
|
||||
/// All known layout variants.
|
||||
pub fn all() -> &'static [KeyboardLayout] {
|
||||
&[
|
||||
KeyboardLayout::Qwerty,
|
||||
KeyboardLayout::Azerty,
|
||||
KeyboardLayout::Qwertz,
|
||||
KeyboardLayout::Dvorak,
|
||||
KeyboardLayout::Colemak,
|
||||
KeyboardLayout::Bepo,
|
||||
KeyboardLayout::QwertyEs,
|
||||
KeyboardLayout::QwertyPt,
|
||||
KeyboardLayout::QwertyIt,
|
||||
KeyboardLayout::QwertyNordic,
|
||||
KeyboardLayout::QwertyBr,
|
||||
KeyboardLayout::QwertyTr,
|
||||
KeyboardLayout::QwertyUk,
|
||||
]
|
||||
}
|
||||
|
||||
/// Human-readable display name.
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
KeyboardLayout::Qwerty => "QWERTY",
|
||||
KeyboardLayout::Azerty => "AZERTY",
|
||||
KeyboardLayout::Qwertz => "QWERTZ",
|
||||
KeyboardLayout::Dvorak => "DVORAK",
|
||||
KeyboardLayout::Colemak => "COLEMAK",
|
||||
KeyboardLayout::Bepo => "BEPO",
|
||||
KeyboardLayout::QwertyEs => "QWERTY_ES",
|
||||
KeyboardLayout::QwertyPt => "QWERTY_PT",
|
||||
KeyboardLayout::QwertyIt => "QWERTY_IT",
|
||||
KeyboardLayout::QwertyNordic => "QWERTY_NORDIC",
|
||||
KeyboardLayout::QwertyBr => "QWERTY_BR",
|
||||
KeyboardLayout::QwertyTr => "QWERTY_TR",
|
||||
KeyboardLayout::QwertyUk => "QWERTY_UK",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a layout name (case-insensitive). Falls back to `Qwerty`.
|
||||
pub fn from_name(s: &str) -> Self {
|
||||
match s.to_ascii_uppercase().as_str() {
|
||||
"QWERTY" => KeyboardLayout::Qwerty,
|
||||
"AZERTY" => KeyboardLayout::Azerty,
|
||||
"QWERTZ" => KeyboardLayout::Qwertz,
|
||||
"DVORAK" => KeyboardLayout::Dvorak,
|
||||
"COLEMAK" => KeyboardLayout::Colemak,
|
||||
"BEPO" | "BÉPO" => KeyboardLayout::Bepo,
|
||||
"QWERTY_ES" => KeyboardLayout::QwertyEs,
|
||||
"QWERTY_PT" => KeyboardLayout::QwertyPt,
|
||||
"QWERTY_IT" => KeyboardLayout::QwertyIt,
|
||||
"QWERTY_NORDIC" => KeyboardLayout::QwertyNordic,
|
||||
"QWERTY_BR" => KeyboardLayout::QwertyBr,
|
||||
"QWERTY_TR" => KeyboardLayout::QwertyTr,
|
||||
"QWERTY_UK" => KeyboardLayout::QwertyUk,
|
||||
_ => KeyboardLayout::Qwerty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a `layout` and an HID key name (e.g. `"A"`, `"COMMA"`, `"SEMICOLON"`),
|
||||
/// returns the visual label for that key on the given layout, or `None` when no
|
||||
/// override exists (meaning the default / QWERTY label applies).
|
||||
///
|
||||
/// The lookup is **case-insensitive** on `hid_name`.
|
||||
pub fn remap_key_label(layout: &KeyboardLayout, hid_name: &str) -> Option<&'static str> {
|
||||
// Normalise to uppercase for matching.
|
||||
let key = hid_name.to_ascii_uppercase();
|
||||
let key = key.as_str();
|
||||
|
||||
match layout {
|
||||
// QWERTY has no overrides — it *is* the reference layout.
|
||||
KeyboardLayout::Qwerty => None,
|
||||
|
||||
KeyboardLayout::Azerty => match key {
|
||||
"COMMA" | "COMM" | "COMA" => Some(";"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some(","),
|
||||
"PERIOD" | "DOT" => Some(":"),
|
||||
"SLASH" | "SLSH" | "/" => Some("!"),
|
||||
"M" => Some(","),
|
||||
"W" => Some("Z"),
|
||||
"Z" => Some("W"),
|
||||
"Q" => Some("A"),
|
||||
"A" => Some("Q"),
|
||||
"," => Some(";"),
|
||||
"." => Some(":"),
|
||||
";" => Some("M"),
|
||||
"-" | "MINUS" | "MIN" => Some(")"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" | "[" => Some("^"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" | "]" => Some("$"),
|
||||
"BACKSLASH" | "BSLSH" | "\\" => Some("<"),
|
||||
"APOSTROPHE" | "APO" | "QUOT" | "'" => Some("\u{00f9}"), // ù
|
||||
"1" => Some("& 1"),
|
||||
"2" => Some("\u{00e9} 2 ~"), // é 2 ~
|
||||
"3" => Some("\" 3 #"),
|
||||
"4" => Some("' 4 }"),
|
||||
"5" => Some("( 5 ["),
|
||||
"6" => Some("- 6 |"),
|
||||
"7" => Some("\u{00e8} 7 `"), // è 7 `
|
||||
"8" => Some("_ 8 \\"),
|
||||
"9" => Some("\u{00e7} 9 ^"), // ç 9 ^
|
||||
"0" => Some("\u{00e0} 0 @"), // à 0 @
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Qwertz => match key {
|
||||
"Y" => Some("Z"),
|
||||
"Z" => Some("Y"),
|
||||
"MINUS" | "MIN" => Some("\u{00df}"), // ß
|
||||
"EQUAL" | "EQL" => Some("'"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00fc}"), // ü
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f6}"), // ö
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e4}"), // ä
|
||||
"GRAVE" | "GRV" => Some("^"),
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("#"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Dvorak => match key {
|
||||
"Q" => Some("'"),
|
||||
"W" => Some(","),
|
||||
"E" => Some("."),
|
||||
"R" => Some("P"),
|
||||
"T" => Some("Y"),
|
||||
"Y" => Some("F"),
|
||||
"U" => Some("G"),
|
||||
"I" => Some("C"),
|
||||
"O" => Some("R"),
|
||||
"P" => Some("L"),
|
||||
"A" => Some("A"),
|
||||
"S" => Some("O"),
|
||||
"D" => Some("E"),
|
||||
"F" => Some("U"),
|
||||
"G" => Some("I"),
|
||||
"H" => Some("D"),
|
||||
"J" => Some("H"),
|
||||
"K" => Some("T"),
|
||||
"L" => Some("N"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("S"),
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("-"),
|
||||
"Z" => Some(";"),
|
||||
"X" => Some("Q"),
|
||||
"C" => Some("J"),
|
||||
"V" => Some("K"),
|
||||
"B" => Some("X"),
|
||||
"N" => Some("B"),
|
||||
"M" => Some("M"),
|
||||
"COMMA" | "COMM" | "COMA" => Some("W"),
|
||||
"PERIOD" | "DOT" => Some("V"),
|
||||
"SLASH" | "SLSH" => Some("Z"),
|
||||
"MINUS" | "MIN" => Some("["),
|
||||
"EQUAL" | "EQL" => Some("]"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("/"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("="),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Colemak => match key {
|
||||
"E" => Some("F"),
|
||||
"R" => Some("P"),
|
||||
"T" => Some("G"),
|
||||
"Y" => Some("J"),
|
||||
"U" => Some("L"),
|
||||
"I" => Some("U"),
|
||||
"O" => Some("Y"),
|
||||
"P" => Some(";"),
|
||||
"S" => Some("R"),
|
||||
"D" => Some("S"),
|
||||
"F" => Some("T"),
|
||||
"G" => Some("D"),
|
||||
"J" => Some("N"),
|
||||
"K" => Some("E"),
|
||||
"L" => Some("I"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("O"),
|
||||
"N" => Some("K"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::Bepo => match key {
|
||||
"Q" => Some("B"),
|
||||
"W" => Some("E"),
|
||||
"E" => Some("P"),
|
||||
"R" => Some("O"),
|
||||
"T" => Some("E"),
|
||||
"Y" => Some("^"),
|
||||
"U" => Some("V"),
|
||||
"I" => Some("D"),
|
||||
"O" => Some("L"),
|
||||
"P" => Some("J"),
|
||||
"A" => Some("A"),
|
||||
"S" => Some("U"),
|
||||
"D" => Some("I"),
|
||||
"F" => Some("E"),
|
||||
"G" => Some(","),
|
||||
"H" => Some("C"),
|
||||
"J" => Some("T"),
|
||||
"K" => Some("S"),
|
||||
"L" => Some("R"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("N"),
|
||||
"Z" => Some("A"),
|
||||
"X" => Some("Y"),
|
||||
"C" => Some("X"),
|
||||
"V" => Some("."),
|
||||
"B" => Some("K"),
|
||||
"N" => Some("'"),
|
||||
"M" => Some("Q"),
|
||||
"COMMA" | "COMM" | "COMA" => Some("G"),
|
||||
"PERIOD" | "DOT" => Some("H"),
|
||||
"SLASH" | "SLSH" => Some("F"),
|
||||
"1" => Some("\""),
|
||||
"2" => Some("<"),
|
||||
"3" => Some(">"),
|
||||
"4" => Some("("),
|
||||
"5" => Some(")"),
|
||||
"6" => Some("@"),
|
||||
"7" => Some("+"),
|
||||
"8" => Some("-"),
|
||||
"9" => Some("/"),
|
||||
"0" => Some("*"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyEs => match key {
|
||||
"MINUS" | "MIN" => Some("'"),
|
||||
"EQUAL" | "EQL" => Some("\u{00a1}"), // ¡
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("`"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f1}"), // ñ
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("'"),
|
||||
"GRAVE" | "GRV" => Some("\u{00ba}"), // º
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("\u{00e7}"), // ç
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyPt => match key {
|
||||
"MINUS" | "MIN" => Some("'"),
|
||||
"EQUAL" | "EQL" => Some("\u{00ab}"), // «
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("+"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("'"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00e7}"), // ç
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00ba}"), // º
|
||||
"GRAVE" | "GRV" => Some("\\"),
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("~"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyIt => match key {
|
||||
"MINUS" | "MIN" => Some("'"),
|
||||
"EQUAL" | "EQL" => Some("\u{00ec}"), // ì
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00e8}"), // è
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("+"),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f2}"), // ò
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e0}"), // à
|
||||
"GRAVE" | "GRV" => Some("\\"),
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("\u{00f9}"), // ù
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyNordic => match key {
|
||||
"MINUS" | "MIN" => Some("+"),
|
||||
"EQUAL" | "EQL" => Some("'"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{00e5}"), // å
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("\u{00a8}"), // ¨
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00f6}"), // ö
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("\u{00e4}"), // ä
|
||||
"GRAVE" | "GRV" => Some("\u{00a7}"), // §
|
||||
"SLASH" | "SLSH" => Some("-"),
|
||||
"BACKSLASH" | "BSLSH" => Some("'"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyBr => match key {
|
||||
"MINUS" | "MIN" => Some("-"),
|
||||
"EQUAL" | "EQL" => Some("="),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("'"),
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("["),
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{00e7}"), // ç
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("~"),
|
||||
"GRAVE" | "GRV" => Some("'"),
|
||||
"SLASH" | "SLSH" => Some(";"),
|
||||
"BACKSLASH" | "BSLSH" => Some("]"),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyTr => match key {
|
||||
"MINUS" | "MIN" => Some("*"),
|
||||
"EQUAL" | "EQL" => Some("-"),
|
||||
"BRACKET_LEFT" | "LBRCKT" | "LBRC" => Some("\u{011f}"), // ğ
|
||||
"BRACKET_RIGHT" | "RBRCKT" | "RBRC" => Some("\u{00fc}"), // ü
|
||||
"SEMICOLON" | "SCOLON" | "SCLN" => Some("\u{015f}"), // ş
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("i"),
|
||||
"GRAVE" | "GRV" => Some("\""),
|
||||
"SLASH" | "SLSH" => Some("."),
|
||||
"BACKSLASH" | "BSLSH" => Some(","),
|
||||
_ => None,
|
||||
},
|
||||
|
||||
KeyboardLayout::QwertyUk => match key {
|
||||
"GRAVE" | "GRV" => Some("`"),
|
||||
"MINUS" | "MIN" => Some("-"),
|
||||
"EQUAL" | "EQL" => Some("="),
|
||||
"BACKSLASH" | "BSLSH" => Some("#"),
|
||||
"APOSTROPHE" | "APO" | "QUOT" => Some("'"),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
#[allow(dead_code)]
|
||||
pub mod binary;
|
||||
pub mod config_io;
|
||||
pub mod binary_protocol;
|
||||
#[allow(dead_code)]
|
||||
pub mod flasher;
|
||||
#[allow(dead_code)]
|
||||
|
|
@ -11,10 +10,10 @@ pub mod layout_remap;
|
|||
#[allow(dead_code)]
|
||||
pub mod parsers;
|
||||
#[allow(dead_code)]
|
||||
pub mod text_commands;
|
||||
pub mod protocol;
|
||||
#[allow(dead_code)]
|
||||
pub mod serial;
|
||||
#[allow(dead_code)]
|
||||
pub mod settings;
|
||||
#[allow(dead_code)]
|
||||
pub mod stats;
|
||||
pub mod stats_analyzer;
|
||||
65
src/logic/protocol.rs
Normal file
65
src/logic/protocol.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#![allow(dead_code)]
|
||||
/// CDC protocol command helpers for KaSe keyboard firmware.
|
||||
|
||||
// Text-based query commands
|
||||
pub const CMD_TAP_DANCE: &str = "TD?";
|
||||
pub const CMD_COMBOS: &str = "COMBO?";
|
||||
pub const CMD_LEADER: &str = "LEADER?";
|
||||
pub const CMD_KEY_OVERRIDE: &str = "KO?";
|
||||
pub const CMD_BT_STATUS: &str = "BT?";
|
||||
pub const CMD_WPM: &str = "WPM?";
|
||||
pub const CMD_TAMA: &str = "TAMA?";
|
||||
pub const CMD_MACROS_TEXT: &str = "MACROS?";
|
||||
pub const CMD_FEATURES: &str = "FEATURES?";
|
||||
pub const CMD_KEYSTATS: &str = "KEYSTATS?";
|
||||
pub const CMD_BIGRAMS: &str = "BIGRAMS?";
|
||||
|
||||
pub fn cmd_set_key(layer: u8, row: u8, col: u8, keycode: u16) -> String {
|
||||
format!("SETKEY {},{},{},{:04X}", layer, row, col, keycode)
|
||||
}
|
||||
|
||||
pub fn cmd_set_layer_name(layer: u8, name: &str) -> String {
|
||||
format!("LAYOUTNAME{}:{}", layer, name)
|
||||
}
|
||||
|
||||
pub fn cmd_bt_switch(slot: u8) -> String {
|
||||
format!("BT SWITCH {}", slot)
|
||||
}
|
||||
|
||||
pub fn cmd_trilayer(l1: u8, l2: u8, l3: u8) -> String {
|
||||
format!("TRILAYER {},{},{}", l1, l2, l3)
|
||||
}
|
||||
|
||||
pub fn cmd_macroseq(slot: u8, name: &str, steps: &str) -> String {
|
||||
format!("MACROSEQ {};{};{}", slot, name, steps)
|
||||
}
|
||||
|
||||
pub fn cmd_macro_del(slot: u8) -> String {
|
||||
format!("MACRODEL {}", slot)
|
||||
}
|
||||
|
||||
pub fn cmd_comboset(index: u8, r1: u8, c1: u8, r2: u8, c2: u8, result: u8) -> String {
|
||||
format!("COMBOSET {};{},{},{},{},{:02X}", index, r1, c1, r2, c2, result)
|
||||
}
|
||||
|
||||
pub fn cmd_combodel(index: u8) -> String {
|
||||
format!("COMBODEL {}", index)
|
||||
}
|
||||
|
||||
pub fn cmd_koset(index: u8, trig_key: u8, trig_mod: u8, res_key: u8, res_mod: u8) -> String {
|
||||
format!("KOSET {};{:02X},{:02X},{:02X},{:02X}", index, trig_key, trig_mod, res_key, res_mod)
|
||||
}
|
||||
|
||||
pub fn cmd_kodel(index: u8) -> String {
|
||||
format!("KODEL {}", index)
|
||||
}
|
||||
|
||||
pub fn cmd_leaderset(index: u8, sequence: &[u8], result: u8, result_mod: u8) -> String {
|
||||
let seq_hex: Vec<String> = sequence.iter().map(|k| format!("{:02X}", k)).collect();
|
||||
let seq_str = seq_hex.join(",");
|
||||
format!("LEADERSET {};{};{:02X},{:02X}", index, seq_str, result, result_mod)
|
||||
}
|
||||
|
||||
pub fn cmd_leaderdel(index: u8) -> String {
|
||||
format!("LEADERDEL {}", index)
|
||||
}
|
||||
15
src/logic/serial/mod.rs
Normal file
15
src/logic/serial/mod.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/// Serial communication module.
|
||||
/// Dispatches to native (serialport crate) or web (WebSerial API)
|
||||
/// depending on the target architecture.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::*;
|
||||
|
|
@ -3,8 +3,8 @@ use std::io::{BufRead, BufReader, Read, Write};
|
|||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::protocol::binary::{self as bp, KrResponse};
|
||||
use crate::protocol::parsers::{ROWS, COLS};
|
||||
use crate::logic::binary_protocol::{self as bp, KrResponse};
|
||||
use crate::logic::parsers::{ROWS, COLS};
|
||||
|
||||
const BAUD_RATE: u32 = 115200;
|
||||
const CONNECT_TIMEOUT_MS: u64 = 300;
|
||||
118
src/logic/settings.rs
Normal file
118
src/logic/settings.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/// Persistent settings for KaSe Controller.
|
||||
///
|
||||
/// - **Native**: `kase_settings.json` next to the executable.
|
||||
/// - **WASM**: `localStorage` under key `"kase_settings"`.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default = "default_layout")]
|
||||
pub keyboard_layout: String,
|
||||
}
|
||||
|
||||
fn default_layout() -> String {
|
||||
"QWERTY".to_string()
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keyboard_layout: default_layout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Native: JSON file next to executable
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_settings {
|
||||
use super::Settings;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Config file path: next to the executable.
|
||||
fn settings_path() -> PathBuf {
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let parent_dir = exe.parent().unwrap_or(std::path::Path::new("."));
|
||||
parent_dir.join("kase_settings.json")
|
||||
}
|
||||
|
||||
pub fn load() -> Settings {
|
||||
let path = settings_path();
|
||||
let json_content = std::fs::read_to_string(&path).ok();
|
||||
let parsed = json_content.and_then(|s| serde_json::from_str(&s).ok());
|
||||
parsed.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(settings: &Settings) {
|
||||
let path = settings_path();
|
||||
let json_result = serde_json::to_string_pretty(settings);
|
||||
if let Ok(json) = json_result {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WASM: browser localStorage
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web_settings {
|
||||
use super::Settings;
|
||||
|
||||
const STORAGE_KEY: &str = "kase_settings";
|
||||
|
||||
/// Get localStorage. Returns None if not in a browser context.
|
||||
fn get_storage() -> Option<web_sys::Storage> {
|
||||
let window = web_sys::window()?;
|
||||
window.local_storage().ok().flatten()
|
||||
}
|
||||
|
||||
pub fn load() -> Settings {
|
||||
let storage = match get_storage() {
|
||||
Some(s) => s,
|
||||
None => return Settings::default(),
|
||||
};
|
||||
|
||||
let json_option = storage.get_item(STORAGE_KEY).ok().flatten();
|
||||
let parsed = json_option.and_then(|s| serde_json::from_str(&s).ok());
|
||||
parsed.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(settings: &Settings) {
|
||||
let storage = match get_storage() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let json_result = serde_json::to_string(settings);
|
||||
if let Ok(json) = json_result {
|
||||
let _ = storage.set_item(STORAGE_KEY, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public interface
|
||||
// =============================================================================
|
||||
|
||||
/// Load settings from persistent storage.
|
||||
/// Returns `Settings::default()` if none found.
|
||||
pub fn load() -> Settings {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return native_settings::load();
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return web_settings::load();
|
||||
}
|
||||
|
||||
/// Save settings to persistent storage. Fails silently.
|
||||
pub fn save(settings: &Settings) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
native_settings::save(settings);
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_settings::save(settings);
|
||||
}
|
||||
182
src/macros.rs
182
src/macros.rs
|
|
@ -1,182 +0,0 @@
|
|||
use crate::context::{AppContext, BgMsg};
|
||||
use crate::protocol;
|
||||
use crate::protocol::keycode;
|
||||
use crate::{AppState, MacroBridge, MacroStepDisplay, MainWindow};
|
||||
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Build a closure that refreshes the macro step display from `ctx.macro_steps`.
|
||||
pub fn make_refresh_display(window: &MainWindow, ctx: &AppContext) -> Rc<dyn Fn()> {
|
||||
let macro_steps = ctx.macro_steps.clone();
|
||||
let window_weak = window.as_weak();
|
||||
Rc::new(move || {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let steps = macro_steps.borrow();
|
||||
let display: Vec<MacroStepDisplay> = steps.iter().map(|&(kc, _md)| {
|
||||
if kc == 0xFF {
|
||||
MacroStepDisplay {
|
||||
label: SharedString::from(format!("T {}ms", _md as u32 * 10)),
|
||||
is_delay: true,
|
||||
}
|
||||
} else {
|
||||
MacroStepDisplay {
|
||||
label: SharedString::from(keycode::hid_key_name(kc)),
|
||||
is_delay: false,
|
||||
}
|
||||
}
|
||||
}).collect();
|
||||
let text: Vec<String> = steps.iter().map(|&(kc, md)| {
|
||||
if kc == 0xFF { format!("T({})", md as u32 * 10) }
|
||||
else { format!("D({:02X})", kc) }
|
||||
}).collect();
|
||||
let mb = w.global::<MacroBridge>();
|
||||
mb.set_new_steps(ModelRc::from(Rc::new(VecModel::from(display))));
|
||||
mb.set_new_steps_text(SharedString::from(text.join(" ")));
|
||||
})
|
||||
}
|
||||
|
||||
/// Wire up all macro callbacks: refresh, add delay, add shortcut, remove last, clear, save, delete.
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
// --- Macros: refresh ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<MacroBridge>().on_refresh_macros(move || {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if ser.v2 {
|
||||
if let Ok(resp) = ser.send_binary(protocol::binary::cmd::LIST_MACROS, &[]) {
|
||||
let macros = protocol::parsers::parse_macros_binary(&resp.payload);
|
||||
let _ = tx.send(BgMsg::MacroList(macros));
|
||||
}
|
||||
} else {
|
||||
// Legacy fallback — should not happen with v2 firmware
|
||||
let _ = ser.send_binary(protocol::binary::cmd::LIST_MACROS, &[]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Macros: add delay step ---
|
||||
{
|
||||
let macro_steps = ctx.macro_steps.clone();
|
||||
let refresh = make_refresh_display(window, ctx);
|
||||
|
||||
window.global::<MacroBridge>().on_add_delay_step(move |ms| {
|
||||
let units = (ms as u8) / 10;
|
||||
macro_steps.borrow_mut().push((0xFF, units));
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Macros: add shortcut preset ---
|
||||
{
|
||||
let macro_steps = ctx.macro_steps.clone();
|
||||
let refresh = make_refresh_display(window, ctx);
|
||||
|
||||
window.global::<MacroBridge>().on_add_shortcut(move |shortcut| {
|
||||
let ctrl = |key: u8| vec![(0xE0u8, 0u8), (key, 0)];
|
||||
let steps: Vec<(u8, u8)> = match shortcut.as_str() {
|
||||
"ctrl+c" => ctrl(0x06),
|
||||
"ctrl+v" => ctrl(0x19),
|
||||
"ctrl+x" => ctrl(0x1B),
|
||||
"ctrl+z" => ctrl(0x1D),
|
||||
"ctrl+y" => ctrl(0x1C),
|
||||
"ctrl+s" => ctrl(0x16),
|
||||
"ctrl+a" => ctrl(0x04),
|
||||
"alt+f4" => vec![(0xE2, 0), (0x3D, 0)],
|
||||
_ => return,
|
||||
};
|
||||
macro_steps.borrow_mut().extend(steps);
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Macros: remove last step ---
|
||||
{
|
||||
let macro_steps = ctx.macro_steps.clone();
|
||||
let refresh = make_refresh_display(window, ctx);
|
||||
|
||||
window.global::<MacroBridge>().on_remove_last_step(move || {
|
||||
macro_steps.borrow_mut().pop();
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Macros: clear steps ---
|
||||
{
|
||||
let macro_steps = ctx.macro_steps.clone();
|
||||
let refresh = make_refresh_display(window, ctx);
|
||||
|
||||
window.global::<MacroBridge>().on_clear_steps(move || {
|
||||
macro_steps.borrow_mut().clear();
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Macros: save ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let macro_steps = ctx.macro_steps.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<MacroBridge>().on_save_macro(move || {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let mb = w.global::<MacroBridge>();
|
||||
let slot_num = mb.get_new_slot_idx() as u8;
|
||||
mb.set_new_slot_idx(slot_num as i32 + 1);
|
||||
let name = mb.get_new_name().to_string();
|
||||
let steps = macro_steps.borrow();
|
||||
let steps_str: Vec<String> = steps.iter().map(|&(kc, md)| {
|
||||
format!("{:02X}:{:02X}", kc, md)
|
||||
}).collect();
|
||||
let steps_text = steps_str.join(",");
|
||||
drop(steps);
|
||||
let payload = protocol::binary::macro_add_seq_payload(slot_num, &name, &steps_text);
|
||||
|
||||
macro_steps.borrow_mut().clear();
|
||||
mb.set_new_name(SharedString::default());
|
||||
mb.set_new_steps(ModelRc::from(Rc::new(VecModel::<MacroStepDisplay>::default())));
|
||||
mb.set_new_steps_text(SharedString::default());
|
||||
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = ser.send_binary(cmd::MACRO_ADD_SEQ, &payload);
|
||||
if let Ok(resp) = ser.send_binary(cmd::LIST_MACROS, &[]) {
|
||||
let macros = protocol::parsers::parse_macros_binary(&resp.payload);
|
||||
let _ = tx.send(BgMsg::MacroList(macros));
|
||||
}
|
||||
});
|
||||
w.global::<AppState>().set_status_text(
|
||||
SharedString::from(format!("Saving macro #{}...", slot_num))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Macros: delete ---
|
||||
{
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<MacroBridge>().on_delete_macro(move |slot| {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = ser.send_binary(cmd::MACRO_DELETE, &protocol::binary::macro_delete_payload(slot as u8));
|
||||
if let Ok(resp) = ser.send_binary(cmd::LIST_MACROS, &[]) {
|
||||
let macros = protocol::parsers::parse_macros_binary(&resp.payload);
|
||||
let _ = tx.send(BgMsg::MacroList(macros));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
1981
src/main.rs
1981
src/main.rs
File diff suppressed because it is too large
Load diff
270
src/models.rs
270
src/models.rs
|
|
@ -1,270 +0,0 @@
|
|||
use crate::protocol::{self as protocol, keycode, layout::KeycapPos, layout_remap};
|
||||
use crate::{
|
||||
KeycapData, KeyEntry, KeySelectorBridge, KeymapBridge, LayoutBridge, LayerInfo, MainWindow,
|
||||
SettingsBridge,
|
||||
};
|
||||
use slint::{ComponentHandle, Model, ModelRc, SharedString, VecModel};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Initialize all UI models from default state.
|
||||
pub fn init_models(
|
||||
window: &MainWindow,
|
||||
keys: &[KeycapPos],
|
||||
saved_settings: &crate::protocol::settings::Settings,
|
||||
) {
|
||||
let kb = window.global::<KeymapBridge>();
|
||||
kb.set_keycaps(ModelRc::from(build_keycap_model(keys)));
|
||||
kb.set_layers(ModelRc::from(build_layer_model(&[
|
||||
"Layer 0".into(), "Layer 1".into(), "Layer 2".into(), "Layer 3".into(),
|
||||
])));
|
||||
let mut max_x: f32 = 0.0;
|
||||
let mut max_y: f32 = 0.0;
|
||||
for kp in keys {
|
||||
if kp.x + kp.w > max_x { max_x = kp.x + kp.w; }
|
||||
if kp.y + kp.h > max_y { max_y = kp.y + kp.h; }
|
||||
}
|
||||
kb.set_content_width(max_x);
|
||||
kb.set_content_height(max_y);
|
||||
|
||||
// Available keyboard layouts
|
||||
let layouts: Vec<SharedString> = layout_remap::KeyboardLayout::all()
|
||||
.iter()
|
||||
.map(|l| SharedString::from(l.name()))
|
||||
.collect();
|
||||
window.global::<SettingsBridge>().set_available_layouts(
|
||||
ModelRc::from(Rc::new(VecModel::from(layouts))),
|
||||
);
|
||||
|
||||
// Initial layout index
|
||||
let current = layout_remap::KeyboardLayout::from_name(&saved_settings.keyboard_layout);
|
||||
let idx = layout_remap::KeyboardLayout::all()
|
||||
.iter()
|
||||
.position(|l| *l == current)
|
||||
.unwrap_or(0);
|
||||
window.global::<SettingsBridge>().set_selected_layout_index(idx as i32);
|
||||
|
||||
// Key selector
|
||||
let all_keys = build_key_entries_with_layout(¤t);
|
||||
window.global::<KeySelectorBridge>().set_all_keys(ModelRc::from(all_keys.clone()));
|
||||
populate_key_categories(window, &all_keys, "");
|
||||
}
|
||||
|
||||
pub fn build_keycap_model(keys: &[KeycapPos]) -> Rc<VecModel<KeycapData>> {
|
||||
let keycaps: Vec<KeycapData> = 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!("{},{}", kp.col, kp.row)),
|
||||
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();
|
||||
Rc::new(VecModel::from(keycaps))
|
||||
}
|
||||
|
||||
pub fn build_layer_model(names: &[String]) -> Rc<VecModel<LayerInfo>> {
|
||||
let layers: Vec<LayerInfo> = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| LayerInfo {
|
||||
index: i as i32,
|
||||
name: SharedString::from(name.as_str()),
|
||||
active: i == 0,
|
||||
})
|
||||
.collect();
|
||||
Rc::new(VecModel::from(layers))
|
||||
}
|
||||
|
||||
pub fn update_keycap_labels(
|
||||
keycap_model: &impl Model<Data = KeycapData>,
|
||||
keys: &[KeycapPos],
|
||||
keymap: &[Vec<u16>],
|
||||
layout: &layout_remap::KeyboardLayout,
|
||||
) {
|
||||
for i in 0..keycap_model.row_count() {
|
||||
if i >= keys.len() { break; }
|
||||
let mut item = keycap_model.row_data(i).unwrap();
|
||||
let kp = &keys[i];
|
||||
let row = kp.row;
|
||||
let col = kp.col;
|
||||
|
||||
if row < keymap.len() && col < keymap[row].len() {
|
||||
let code = keymap[row][col];
|
||||
let decoded = keycode::decode_keycode(code);
|
||||
let remapped = layout_remap::remap_key_label(layout, &decoded);
|
||||
let label = remapped.unwrap_or(&decoded).to_string();
|
||||
item.keycode = code as i32;
|
||||
item.label = SharedString::from(label);
|
||||
item.sublabel = if decoded != format!("0x{:04X}", code) {
|
||||
SharedString::default()
|
||||
} else {
|
||||
SharedString::from(format!("0x{:04X}", code))
|
||||
};
|
||||
}
|
||||
keycap_model.set_row_data(i, item);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_key_entries_with_layout(layout: &layout_remap::KeyboardLayout) -> Rc<VecModel<KeyEntry>> {
|
||||
let hid_entry = |code: u16, category: &str| -> KeyEntry {
|
||||
let base = keycode::hid_key_name(code as u8);
|
||||
let name = layout_remap::remap_key_label(layout, &base)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(base);
|
||||
KeyEntry { name: SharedString::from(name), code: code as i32, category: SharedString::from(category) }
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for code in 0x04u16..=0x1D { entries.push(hid_entry(code, "Letter")); }
|
||||
for code in 0x1Eu16..=0x27 { entries.push(hid_entry(code, "Number")); }
|
||||
for code in [0x28u16, 0x29, 0x2A, 0x2B, 0x2C] { entries.push(hid_entry(code, "Control")); }
|
||||
for code in 0x2Du16..=0x38 { entries.push(hid_entry(code, "Symbol")); }
|
||||
for code in 0x3Au16..=0x45 { entries.push(hid_entry(code, "Function")); }
|
||||
for code in [0x46u16, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52] {
|
||||
entries.push(hid_entry(code, "Navigation"));
|
||||
}
|
||||
for code in 0xE0u16..=0xE7 { entries.push(hid_entry(code, "Modifier")); }
|
||||
entries.push(KeyEntry {
|
||||
name: SharedString::from("Caps Lock"),
|
||||
code: 0x39,
|
||||
category: SharedString::from("Control"),
|
||||
});
|
||||
for code in 0x53u16..=0x63 { entries.push(hid_entry(code, "Keypad")); }
|
||||
for code in 0x68u16..=0x73 { entries.push(hid_entry(code, "Function")); }
|
||||
for code in [0x7Fu16, 0x80, 0x81] { entries.push(hid_entry(code, "Media")); }
|
||||
for (code, name) in [
|
||||
(0x2900u16, "BT Next"), (0x2A00, "BT Prev"), (0x2B00, "BT Pair"),
|
||||
(0x2C00, "BT Disc"), (0x2E00, "USB/BT"), (0x2F00, "BT On/Off"),
|
||||
] {
|
||||
entries.push(KeyEntry {
|
||||
name: SharedString::from(name),
|
||||
code: code as i32,
|
||||
category: SharedString::from("Bluetooth"),
|
||||
});
|
||||
}
|
||||
for i in 0u16..=7 {
|
||||
let code = (0x60 | i) << 8;
|
||||
entries.push(KeyEntry {
|
||||
name: SharedString::from(format!("TD {}", i)),
|
||||
code: code as i32,
|
||||
category: SharedString::from("Tap Dance"),
|
||||
});
|
||||
}
|
||||
for i in 0u16..=9 {
|
||||
let code = (i + 0x15) << 8;
|
||||
entries.push(KeyEntry {
|
||||
name: SharedString::from(format!("M{}", i)),
|
||||
code: code as i32,
|
||||
category: SharedString::from("Macro"),
|
||||
});
|
||||
}
|
||||
for i in 0u16..=9 {
|
||||
entries.push(KeyEntry {
|
||||
name: SharedString::from(format!("OSL {}", i)),
|
||||
code: 0x3100 + i as i32,
|
||||
category: SharedString::from("Layer"),
|
||||
});
|
||||
}
|
||||
for layer in 0u16..=9 {
|
||||
let code = (layer + 1) << 8;
|
||||
entries.push(KeyEntry {
|
||||
name: SharedString::from(format!("MO {}", layer)),
|
||||
code: code as i32,
|
||||
category: SharedString::from("Layer"),
|
||||
});
|
||||
}
|
||||
for layer in 0u16..=9 {
|
||||
let code = (layer + 0x0B) << 8;
|
||||
entries.push(KeyEntry {
|
||||
name: SharedString::from(format!("TO {}", layer)),
|
||||
code: code as i32,
|
||||
category: SharedString::from("Layer"),
|
||||
});
|
||||
}
|
||||
for (code, name) in [
|
||||
(0x3200u16, "Caps Word"), (0x3300, "Repeat"), (0x3400, "Leader"),
|
||||
(0x3900, "GEsc"), (0x3A00, "Layer Lock"), (0x3C00, "AS Toggle"),
|
||||
] {
|
||||
entries.push(KeyEntry {
|
||||
name: SharedString::from(name),
|
||||
code: code as i32,
|
||||
category: SharedString::from("Special"),
|
||||
});
|
||||
}
|
||||
entries.insert(0, KeyEntry {
|
||||
name: SharedString::from("None"),
|
||||
code: 0,
|
||||
category: SharedString::from("Special"),
|
||||
});
|
||||
|
||||
Rc::new(VecModel::from(entries))
|
||||
}
|
||||
|
||||
pub fn populate_key_categories(window: &MainWindow, all_keys: &VecModel<KeyEntry>, search: &str) {
|
||||
let search_lower = search.to_lowercase();
|
||||
let filter = |cat: &str| -> Vec<KeyEntry> {
|
||||
(0..all_keys.row_count())
|
||||
.filter_map(|i| {
|
||||
let e = all_keys.row_data(i).unwrap();
|
||||
let cat_match = e.category.as_str() == cat
|
||||
|| (cat == "Navigation" && (e.category.as_str() == "Control" || e.category.as_str() == "Navigation"))
|
||||
|| (cat == "Special" && (e.category.as_str() == "Special" || e.category.as_str() == "Bluetooth" || e.category.as_str() == "Media"))
|
||||
|| (cat == "TDMacro" && (e.category.as_str() == "Tap Dance" || e.category.as_str() == "Macro"));
|
||||
let search_match = search_lower.is_empty()
|
||||
|| e.name.to_lowercase().contains(&search_lower)
|
||||
|| e.category.to_lowercase().contains(&search_lower);
|
||||
if cat_match && search_match { Some(e) } else { None }
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let set = |model: Vec<KeyEntry>| ModelRc::from(Rc::new(VecModel::from(model)));
|
||||
let ks = window.global::<KeySelectorBridge>();
|
||||
ks.set_cat_letters(set(filter("Letter")));
|
||||
ks.set_cat_numbers(set(filter("Number")));
|
||||
ks.set_cat_modifiers(set(filter("Modifier")));
|
||||
ks.set_cat_nav(set(filter("Navigation")));
|
||||
ks.set_cat_function(set(filter("Function")));
|
||||
ks.set_cat_symbols(set(filter("Symbol")));
|
||||
ks.set_cat_layers(set(filter("Layer")));
|
||||
ks.set_cat_special(set(filter("Special")));
|
||||
ks.set_cat_td_macro(set(filter("TDMacro")));
|
||||
}
|
||||
|
||||
pub fn populate_layout_preview(window: &MainWindow, json: &str) {
|
||||
let lb = window.global::<LayoutBridge>();
|
||||
match protocol::layout::parse_json(json) {
|
||||
Ok(keys) => {
|
||||
let keycaps: Vec<KeycapData> = 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();
|
||||
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(std::rc::Rc::new(VecModel::from(keycaps))));
|
||||
lb.set_status(SharedString::from(format!("{} keys loaded", keys.len())));
|
||||
let pretty_json = serde_json::from_str::<serde_json::Value>(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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
/// 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,104 +0,0 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
/// A keycap with computed absolute position.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct KeycapPos {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
pub angle: f32, // degrees
|
||||
}
|
||||
|
||||
const UNIT_PX: f32 = 50.0; // 1u = 50 pixels
|
||||
|
||||
#[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 layout: LayoutJson = serde_json::from_str(json)
|
||||
.map_err(|e| format!("Invalid layout JSON: {}", e))?;
|
||||
|
||||
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(out)
|
||||
}
|
||||
|
||||
/// Default layout embedded at compile time.
|
||||
pub fn default_layout() -> Vec<KeycapPos> {
|
||||
let json = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/default.json"));
|
||||
parse_json(json).unwrap_or_default()
|
||||
}
|
||||
263
src/settings.rs
263
src/settings.rs
|
|
@ -1,263 +0,0 @@
|
|||
use crate::context::{AppContext, BgMsg};
|
||||
use crate::protocol;
|
||||
use crate::{config, MainWindow, SettingsBridge};
|
||||
use slint::{ComponentHandle, SharedString};
|
||||
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
setup_change_layout(window, ctx);
|
||||
setup_ota_browse(window);
|
||||
setup_ota_start(window, ctx);
|
||||
setup_config_export(window, ctx);
|
||||
setup_config_import(window, ctx);
|
||||
}
|
||||
|
||||
// --- Change keyboard layout (AZERTY / QWERTZ / ...) ---
|
||||
fn setup_change_layout(window: &MainWindow, ctx: &AppContext) {
|
||||
let keyboard_layout = ctx.keyboard_layout.clone();
|
||||
let keys = ctx.keys.clone();
|
||||
let current_keymap = ctx.current_keymap.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<SettingsBridge>().on_change_layout(move |idx| {
|
||||
let all = protocol::layout_remap::KeyboardLayout::all();
|
||||
let new_layout = all.get(idx as usize).copied().unwrap_or(all[0]);
|
||||
|
||||
// Update shared state
|
||||
*keyboard_layout.borrow_mut() = new_layout;
|
||||
|
||||
// Persist to disk
|
||||
let mut s = protocol::settings::load();
|
||||
s.keyboard_layout = new_layout.name().to_string();
|
||||
protocol::settings::save(&s);
|
||||
|
||||
// Refresh keycap labels + key selector popup
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
let keycaps = w.global::<crate::KeymapBridge>().get_keycaps();
|
||||
let km = current_keymap.borrow();
|
||||
let k = keys.borrow();
|
||||
if !km.is_empty() {
|
||||
crate::models::update_keycap_labels(&keycaps, &k, &km, &new_layout);
|
||||
}
|
||||
|
||||
let all_keys = crate::models::build_key_entries_with_layout(&new_layout);
|
||||
w.global::<crate::KeySelectorBridge>().set_all_keys(slint::ModelRc::from(all_keys.clone()));
|
||||
crate::models::populate_key_categories(&w, &all_keys, "");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- OTA: browse ---
|
||||
fn setup_ota_browse(window: &MainWindow) {
|
||||
let window_weak = window.as_weak();
|
||||
window.global::<SettingsBridge>().on_ota_browse(move || {
|
||||
let window_weak = window_weak.clone();
|
||||
std::thread::spawn(move || {
|
||||
let file = rfd::FileDialog::new()
|
||||
.add_filter("Firmware", &["bin"])
|
||||
.pick_file();
|
||||
if let Some(path) = file {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let _ = slint::invoke_from_event_loop(move || {
|
||||
if let Some(w) = window_weak.upgrade() {
|
||||
w.global::<SettingsBridge>().set_ota_path(SharedString::from(path_str.as_str()));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- OTA: start ---
|
||||
fn setup_ota_start(window: &MainWindow, ctx: &AppContext) {
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<SettingsBridge>().on_ota_start(move || {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
let settings = w.global::<SettingsBridge>();
|
||||
let path = settings.get_ota_path().to_string();
|
||||
if path.is_empty() { return; }
|
||||
|
||||
let firmware = match std::fs::read(&path) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
let _ = tx.send(BgMsg::OtaDone(Err(format!("Cannot read {}: {}", path, e))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
settings.set_ota_flashing(true);
|
||||
settings.set_ota_progress(0.0);
|
||||
settings.set_ota_status(SharedString::from("Starting OTA..."));
|
||||
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{Read, Write, BufRead, BufReader};
|
||||
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let total = firmware.len();
|
||||
let chunk_size = 4096usize;
|
||||
|
||||
// Step 1: Send OTA <size> command
|
||||
let cmd = format!("OTA {}", total);
|
||||
if let Err(e) = ser.send_command(&cmd) {
|
||||
let _ = tx.send(BgMsg::OtaDone(Err(format!("Send OTA cmd failed: {}", e))));
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Wait for OTA_READY (read lines with timeout)
|
||||
let _ = tx.send(BgMsg::OtaProgress(0.0, "Waiting for OTA_READY...".into()));
|
||||
let port = match ser.port_mut() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let _ = tx.send(BgMsg::OtaDone(Err("Port not available".into())));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Read until we get OTA_READY or timeout
|
||||
let old_timeout = port.timeout();
|
||||
let _ = port.set_timeout(std::time::Duration::from_secs(5));
|
||||
let mut got_ready = false;
|
||||
let port_clone = port.try_clone().unwrap();
|
||||
let mut reader = BufReader::new(port_clone);
|
||||
for _ in 0..20 {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).is_ok() && line.contains("OTA_READY") {
|
||||
got_ready = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
drop(reader);
|
||||
|
||||
if !got_ready {
|
||||
let _ = port.set_timeout(old_timeout);
|
||||
let _ = tx.send(BgMsg::OtaDone(Err("Firmware did not respond OTA_READY".into())));
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Send chunks and wait for ACK after each
|
||||
let num_chunks = total.div_ceil(chunk_size);
|
||||
let _ = port.set_timeout(std::time::Duration::from_secs(5));
|
||||
|
||||
for (i, chunk) in firmware.chunks(chunk_size).enumerate() {
|
||||
// Send chunk
|
||||
if let Err(e) = port.write_all(chunk) {
|
||||
let _ = port.set_timeout(old_timeout);
|
||||
let _ = tx.send(BgMsg::OtaDone(Err(format!("Write chunk {} failed: {}", i, e))));
|
||||
return;
|
||||
}
|
||||
let _ = port.flush();
|
||||
|
||||
let progress = (i + 1) as f32 / num_chunks as f32;
|
||||
let _ = tx.send(BgMsg::OtaProgress(progress * 0.95, format!(
|
||||
"Chunk {}/{} ({} KB / {} KB)",
|
||||
i + 1, num_chunks,
|
||||
((i + 1) * chunk_size).min(total) / 1024,
|
||||
total / 1024
|
||||
)));
|
||||
|
||||
// Wait for ACK line
|
||||
let mut ack_buf = [0u8; 256];
|
||||
let mut ack = String::new();
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < std::time::Duration::from_secs(5) {
|
||||
match port.read(&mut ack_buf) {
|
||||
Ok(n) if n > 0 => {
|
||||
ack.push_str(&String::from_utf8_lossy(&ack_buf[..n]));
|
||||
if ack.contains("OTA_OK") || ack.contains("OTA_DONE") || ack.contains("OTA_FAIL") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => std::thread::sleep(std::time::Duration::from_millis(10)),
|
||||
}
|
||||
}
|
||||
|
||||
if ack.contains("OTA_FAIL") {
|
||||
let _ = port.set_timeout(old_timeout);
|
||||
let _ = tx.send(BgMsg::OtaDone(Err(format!("Firmware error: {}", ack.trim()))));
|
||||
return;
|
||||
}
|
||||
if ack.contains("OTA_DONE") {
|
||||
break; // Firmware signals all received
|
||||
}
|
||||
}
|
||||
|
||||
let _ = port.set_timeout(old_timeout);
|
||||
let _ = tx.send(BgMsg::OtaProgress(1.0, "OTA complete, rebooting...".into()));
|
||||
let _ = tx.send(BgMsg::OtaDone(Ok(())));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Config Export ---
|
||||
fn setup_config_export(window: &MainWindow, ctx: &AppContext) {
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<SettingsBridge>().on_config_export(move || {
|
||||
let Some(w) = window_weak.upgrade() else { return };
|
||||
w.global::<SettingsBridge>().set_config_busy(true);
|
||||
w.global::<SettingsBridge>().set_config_progress(0.0);
|
||||
w.global::<SettingsBridge>().set_config_status(SharedString::from("Reading config..."));
|
||||
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = config::export_config(&serial, &tx);
|
||||
let _ = tx.send(BgMsg::ConfigDone(result));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Config Import ---
|
||||
fn setup_config_import(window: &MainWindow, ctx: &AppContext) {
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
let window_weak = window.as_weak();
|
||||
|
||||
window.global::<SettingsBridge>().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::<SettingsBridge>();
|
||||
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 protocol::config_io::KeyboardConfig::from_json(&json) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = tx.send(BgMsg::ConfigDone(Err(format!("Parse error: {}", e))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let result = config::import_config(&serial, &tx, &config);
|
||||
let _ = tx.send(BgMsg::ConfigDone(result));
|
||||
});
|
||||
});
|
||||
}
|
||||
28
src/stats.rs
28
src/stats.rs
|
|
@ -1,28 +0,0 @@
|
|||
use crate::context::AppContext;
|
||||
use crate::{MainWindow, StatsBridge};
|
||||
use crate::protocol;
|
||||
use slint::ComponentHandle;
|
||||
|
||||
/// Wire up the stats refresh callback.
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<StatsBridge>().on_refresh_stats(move || {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
use protocol::binary::cmd;
|
||||
use crate::context::BgMsg;
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Ok(r) = ser.send_binary(cmd::KEYSTATS_BIN, &[]) {
|
||||
let (data, max) = protocol::parsers::parse_keystats_binary(&r.payload);
|
||||
let _ = tx.send(BgMsg::HeatmapData(data, max));
|
||||
}
|
||||
let bigram_lines = if let Ok(r) = ser.send_binary(protocol::binary::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));
|
||||
});
|
||||
});
|
||||
}
|
||||
101
src/tools.rs
101
src/tools.rs
|
|
@ -1,101 +0,0 @@
|
|||
use crate::context::{AppContext, BgMsg};
|
||||
use crate::protocol::binary::{self as bp};
|
||||
use crate::{MainWindow, ToolsBridge};
|
||||
use slint::ComponentHandle;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Shared flag to stop the matrix test polling thread.
|
||||
static MATRIX_POLLING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn setup(window: &MainWindow, ctx: &AppContext) {
|
||||
setup_toggle_matrix_test(window, ctx);
|
||||
}
|
||||
|
||||
fn setup_toggle_matrix_test(window: &MainWindow, ctx: &AppContext) {
|
||||
let serial = ctx.serial.clone();
|
||||
let tx = ctx.bg_tx.clone();
|
||||
|
||||
window.global::<ToolsBridge>().on_toggle_matrix_test(move || {
|
||||
let serial = serial.clone();
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut ser = serial.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// Send toggle command
|
||||
match ser.send_binary(bp::cmd::MATRIX_TEST, &[]) {
|
||||
Ok(resp) => {
|
||||
if resp.payload.len() >= 3 {
|
||||
let enabled = resp.payload[0];
|
||||
let rows = resp.payload[1];
|
||||
let cols = resp.payload[2];
|
||||
let _ = tx.send(BgMsg::MatrixTestToggled(enabled != 0, rows, cols));
|
||||
|
||||
if enabled != 0 {
|
||||
// Start polling thread for unsolicited events
|
||||
MATRIX_POLLING.store(true, Ordering::SeqCst);
|
||||
let serial2 = serial.clone();
|
||||
let tx2 = tx.clone();
|
||||
drop(ser); // release lock before spawning poller
|
||||
std::thread::spawn(move || {
|
||||
poll_matrix_events(serial2, tx2);
|
||||
});
|
||||
} else {
|
||||
MATRIX_POLLING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(BgMsg::MatrixTestError(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Poll serial for unsolicited KR [0xB0] events.
|
||||
fn poll_matrix_events(
|
||||
serial: Arc<std::sync::Mutex<crate::protocol::serial::SerialManager>>,
|
||||
tx: std::sync::mpsc::Sender<BgMsg>,
|
||||
) {
|
||||
let mut buf = vec![0u8; 256];
|
||||
|
||||
while MATRIX_POLLING.load(Ordering::SeqCst) {
|
||||
let read_result = {
|
||||
let mut ser = match serial.try_lock() {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let port = match ser.port_mut() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
MATRIX_POLLING.store(false, Ordering::SeqCst);
|
||||
let _ = tx.send(BgMsg::MatrixTestError("Port disconnected".into()));
|
||||
break;
|
||||
}
|
||||
};
|
||||
port.read(&mut buf)
|
||||
};
|
||||
|
||||
match read_result {
|
||||
Ok(n) if n > 0 => {
|
||||
let frames = bp::parse_all_kr(&buf[..n]);
|
||||
for frame in frames {
|
||||
if frame.cmd == bp::cmd::MATRIX_TEST && frame.is_ok() && frame.payload.len() >= 3 {
|
||||
let row = frame.payload[0];
|
||||
let col = frame.payload[1];
|
||||
let state = frame.payload[2];
|
||||
let _ = tx.send(BgMsg::MatrixTestEvent(row, col, state));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { LineEdit } from "std-widgets.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
|
||||
export component DarkLineEdit inherits Rectangle {
|
||||
|
|
@ -8,34 +9,15 @@ export component DarkLineEdit inherits Rectangle {
|
|||
|
||||
forward-focus: inner;
|
||||
height: 28px;
|
||||
min-width: 60px;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
border-color: inner.has-focus ? Theme.accent-purple : Theme.button-border;
|
||||
background: Theme.button-bg;
|
||||
|
||||
// Placeholder
|
||||
if root.text == "" && !inner.has-focus : Text {
|
||||
x: 6px;
|
||||
y: 0;
|
||||
width: root.width - 12px;
|
||||
height: root.height;
|
||||
text: root.placeholder-text;
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
inner := TextInput {
|
||||
x: 6px;
|
||||
y: 0;
|
||||
width: root.width - 12px;
|
||||
height: root.height;
|
||||
inner := LineEdit {
|
||||
text <=> root.text;
|
||||
color: Theme.fg-primary;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
accepted => { root.accepted(root.text); }
|
||||
edited => { root.edited(root.text); }
|
||||
placeholder-text: root.placeholder-text;
|
||||
accepted(t) => { root.accepted(t); }
|
||||
edited(t) => { root.edited(t); }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,12 +81,6 @@ 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 ----
|
||||
|
|
@ -253,7 +247,6 @@ export global MacroBridge {
|
|||
callback save-macro(); // reads slot, name, steps from properties
|
||||
callback delete-macro(int);
|
||||
callback add-delay-step(int); // delay in ms (50, 100, 200, 500)
|
||||
callback add-shortcut(string); // e.g. "ctrl+c", "ctrl+shift+z"
|
||||
callback remove-last-step();
|
||||
callback clear-steps();
|
||||
}
|
||||
|
|
@ -264,7 +257,7 @@ export global FlasherBridge {
|
|||
in property <[string]> prog-ports;
|
||||
in-out property <string> selected-prog-port: "";
|
||||
in-out property <string> firmware-path: "";
|
||||
in-out property <int> flash-offset-index: 1; // 0=full(0x0), 1=factory(0x20000), 2=ota_0(0x220000)
|
||||
in-out property <int> flash-offset-index: 0; // 0=factory(0x20000), 1=ota_0(0x220000)
|
||||
in property <float> flash-progress: 0;
|
||||
in property <string> flash-status: "";
|
||||
in property <bool> flashing: false;
|
||||
|
|
@ -273,34 +266,6 @@ 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();
|
||||
}
|
||||
|
||||
// ---- Matrix Test ----
|
||||
|
||||
export global ToolsBridge {
|
||||
in-out property <bool> matrix-test-active: false;
|
||||
in property <string> matrix-test-status: "";
|
||||
// Keycap model with live press state (reuses KeycapData, color = press state)
|
||||
in property <[KeycapData]> matrix-keycaps;
|
||||
in property <length> matrix-content-width: 860px;
|
||||
in property <length> matrix-content-height: 360px;
|
||||
callback toggle-matrix-test();
|
||||
}
|
||||
|
||||
// ---- 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 { TabTools } from "tabs/tab_tools.slint";
|
||||
import { TabFlasher } from "tabs/tab_flasher.slint";
|
||||
|
||||
export { AppState, Theme }
|
||||
export { ConnectionBridge, KeymapBridge, SettingsBridge, StatsBridge, AdvancedBridge, MacroBridge, FlasherBridge, KeySelectorBridge, LayoutBridge, ToolsBridge } from "globals.slint";
|
||||
export { ConnectionBridge, KeymapBridge, SettingsBridge, StatsBridge, AdvancedBridge, MacroBridge, FlasherBridge, KeySelectorBridge } 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: "Tools"; active: root.current-tab == 5; clicked => { root.current-tab = 5; AppState.tab-changed(5); } }
|
||||
DarkTab { title: "Flash"; 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 : TabTools { }
|
||||
if root.current-tab == 5 : TabFlasher { }
|
||||
}
|
||||
|
||||
StatusBar { }
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export component TabFlasher inherits Rectangle {
|
|||
}
|
||||
|
||||
DarkComboBox {
|
||||
model: ["full (0x0)", "factory (0x20000)", "ota_0 (0x220000)"];
|
||||
model: ["factory (0x20000)", "ota_0 (0x220000)"];
|
||||
current-index <=> FlasherBridge.flash-offset-index;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,199 +1,150 @@
|
|||
import { DarkLineEdit } from "../components/dark_line_edit.slint";
|
||||
import { DarkComboBox } from "../components/dark_combo_box.slint";
|
||||
import { Theme } from "../theme.slint";
|
||||
import { DarkButton } from "../components/dark_button.slint";
|
||||
import { KeymapBridge, AppState, ConnectionState, SettingsBridge } from "../globals.slint";
|
||||
import { KeymapBridge, AppState, ConnectionState } from "../globals.slint";
|
||||
import { KeyboardView } from "../components/keyboard_view.slint";
|
||||
|
||||
export component TabKeymap inherits Rectangle {
|
||||
export component TabKeymap inherits VerticalLayout {
|
||||
in-out property <bool> renaming: false;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 8px;
|
||||
padding: 8px;
|
||||
spacing: 6px;
|
||||
|
||||
// Main area: layers sidebar + keyboard
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 1;
|
||||
spacing: 6px;
|
||||
|
||||
// Main area: layers sidebar + keyboard
|
||||
HorizontalLayout {
|
||||
vertical-stretch: 1;
|
||||
spacing: 6px;
|
||||
// Layer sidebar (vertical)
|
||||
VerticalLayout {
|
||||
width: 90px;
|
||||
spacing: 4px;
|
||||
alignment: start;
|
||||
|
||||
// Layer sidebar (vertical)
|
||||
VerticalLayout {
|
||||
width: 90px;
|
||||
spacing: 4px;
|
||||
alignment: start;
|
||||
Text {
|
||||
text: "Layers";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
VerticalLayout {
|
||||
spacing: 4px;
|
||||
|
||||
for layer[idx] in KeymapBridge.layers : Rectangle {
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
background: layer.active ? Theme.accent-purple : Theme.button-bg;
|
||||
|
||||
Text {
|
||||
text: "Layers";
|
||||
color: Theme.fg-secondary;
|
||||
text: layer.name;
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
VerticalLayout {
|
||||
spacing: 4px;
|
||||
|
||||
for layer[idx] in KeymapBridge.layers : Rectangle {
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
background: layer.active ? Theme.accent-purple : Theme.button-bg;
|
||||
|
||||
Text {
|
||||
text: layer.name;
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
clicked => { KeymapBridge.switch-layer(layer.index); }
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !root.renaming && AppState.connection == ConnectionState.connected : Rectangle {
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background: rename-ta.has-hover ? Theme.button-hover : Theme.button-bg;
|
||||
|
||||
Text {
|
||||
text: "Rename";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 10px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
rename-ta := TouchArea {
|
||||
clicked => { root.renaming = true; }
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { vertical-stretch: 1; }
|
||||
|
||||
// Heatmap toggle
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
background: KeymapBridge.heatmap-enabled ? Theme.accent-orange : Theme.button-bg;
|
||||
|
||||
Text {
|
||||
text: "Heatmap";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
clicked => {
|
||||
KeymapBridge.heatmap-enabled = !KeymapBridge.heatmap-enabled;
|
||||
if KeymapBridge.heatmap-enabled { KeymapBridge.toggle-heatmap(); }
|
||||
}
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard language
|
||||
DarkComboBox {
|
||||
width: 90px;
|
||||
model: SettingsBridge.available-layouts;
|
||||
current-index <=> SettingsBridge.selected-layout-index;
|
||||
selected(value) => { SettingsBridge.change-layout(self.current-index); }
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard view
|
||||
KeyboardView {
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Selected key info bar
|
||||
if KeymapBridge.selected-key-index >= 0 : Rectangle {
|
||||
height: 36px;
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 4px;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 8px;
|
||||
spacing: 12px;
|
||||
|
||||
Text {
|
||||
text: "Selected: " + KeymapBridge.selected-key-label;
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
DarkButton {
|
||||
text: "Change Key...";
|
||||
clicked => {
|
||||
KeymapBridge.key-selector-open = true;
|
||||
}
|
||||
TouchArea {
|
||||
clicked => { KeymapBridge.switch-layer(layer.index); }
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rename popup overlay
|
||||
if root.renaming : Rectangle {
|
||||
background: #000000aa;
|
||||
|
||||
TouchArea {
|
||||
clicked => { root.renaming = false; }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 260px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
background: Theme.bg-secondary;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 12px;
|
||||
alignment: center;
|
||||
|
||||
Text {
|
||||
text: "Rename layer";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Rename
|
||||
if root.renaming : VerticalLayout {
|
||||
spacing: 4px;
|
||||
rename-input := DarkLineEdit {
|
||||
max-width: 85px;
|
||||
placeholder-text: "New name";
|
||||
accepted(text) => {
|
||||
KeymapBridge.rename-layer(KeymapBridge.active-layer, text);
|
||||
root.renaming = false;
|
||||
}
|
||||
}
|
||||
DarkButton {
|
||||
text: "Cancel";
|
||||
clicked => { root.renaming = false; }
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
alignment: center;
|
||||
if !root.renaming && AppState.connection == ConnectionState.connected : Rectangle {
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background: rename-ta.has-hover ? Theme.button-hover : Theme.button-bg;
|
||||
|
||||
DarkButton {
|
||||
text: "Cancel";
|
||||
clicked => { root.renaming = false; }
|
||||
}
|
||||
Text {
|
||||
text: "Rename";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 10px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
DarkButton {
|
||||
text: "Rename";
|
||||
clicked => {
|
||||
KeymapBridge.rename-layer(KeymapBridge.active-layer, rename-input.text);
|
||||
root.renaming = false;
|
||||
}
|
||||
}
|
||||
rename-ta := TouchArea {
|
||||
clicked => { root.renaming = true; }
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { vertical-stretch: 1; }
|
||||
|
||||
// Heatmap toggle
|
||||
Rectangle {
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
background: KeymapBridge.heatmap-enabled ? Theme.accent-orange : Theme.button-bg;
|
||||
|
||||
Text {
|
||||
text: "Heatmap";
|
||||
color: Theme.fg-primary;
|
||||
font-size: 11px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
clicked => {
|
||||
KeymapBridge.heatmap-enabled = !KeymapBridge.heatmap-enabled;
|
||||
if KeymapBridge.heatmap-enabled { KeymapBridge.toggle-heatmap(); }
|
||||
}
|
||||
mouse-cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard view
|
||||
KeyboardView {
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Selected key info bar
|
||||
if KeymapBridge.selected-key-index >= 0 : Rectangle {
|
||||
height: 36px;
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 4px;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 8px;
|
||||
spacing: 12px;
|
||||
|
||||
Text {
|
||||
text: "Selected: " + KeymapBridge.selected-key-label;
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 12px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
DarkButton {
|
||||
text: "Change Key...";
|
||||
clicked => {
|
||||
KeymapBridge.key-selector-open = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ export component TabMacros inherits Rectangle {
|
|||
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
|
||||
Text { text: "Slot:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
|
||||
Text { text: "#" + MacroBridge.new-slot-idx; color: Theme.accent-purple; font-size: 12px; font-weight: 600; vertical-alignment: center; }
|
||||
alignment: start;
|
||||
|
||||
Text { text: "Name:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
|
||||
DarkLineEdit {
|
||||
|
|
@ -104,23 +102,6 @@ export component TabMacros inherits Rectangle {
|
|||
DarkButton { text: "Clear"; clicked => { MacroBridge.clear-steps(); } }
|
||||
}
|
||||
|
||||
// Common shortcuts
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
alignment: start;
|
||||
|
||||
Text { text: "Shortcuts:"; color: Theme.fg-secondary; font-size: 11px; vertical-alignment: center; }
|
||||
|
||||
DarkButton { text: "Ctrl+C"; clicked => { MacroBridge.add-shortcut("ctrl+c"); } }
|
||||
DarkButton { text: "Ctrl+V"; clicked => { MacroBridge.add-shortcut("ctrl+v"); } }
|
||||
DarkButton { text: "Ctrl+X"; clicked => { MacroBridge.add-shortcut("ctrl+x"); } }
|
||||
DarkButton { text: "Ctrl+Z"; clicked => { MacroBridge.add-shortcut("ctrl+z"); } }
|
||||
DarkButton { text: "Ctrl+Y"; clicked => { MacroBridge.add-shortcut("ctrl+y"); } }
|
||||
DarkButton { text: "Ctrl+S"; clicked => { MacroBridge.add-shortcut("ctrl+s"); } }
|
||||
DarkButton { text: "Ctrl+A"; clicked => { MacroBridge.add-shortcut("ctrl+a"); } }
|
||||
DarkButton { text: "Alt+F4"; clicked => { MacroBridge.add-shortcut("alt+f4"); } }
|
||||
}
|
||||
|
||||
// Save
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Theme } from "../theme.slint";
|
||||
import { DarkButton } from "../components/dark_button.slint";
|
||||
import { DarkComboBox } from "../components/dark_combo_box.slint";
|
||||
import { SettingsBridge, AppState, ConnectionState } from "../globals.slint";
|
||||
|
||||
export component TabSettings inherits Rectangle {
|
||||
|
|
@ -17,6 +18,35 @@ export component TabSettings inherits Rectangle {
|
|||
font-weight: 700;
|
||||
}
|
||||
|
||||
// Keyboard layout
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 16px;
|
||||
spacing: 12px;
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text { text: "Keyboard Layout"; color: Theme.fg-primary; font-size: 14px; }
|
||||
Text { text: "Controls how keycodes are displayed"; color: Theme.fg-secondary; font-size: 11px; }
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
DarkComboBox {
|
||||
width: 200px;
|
||||
model: SettingsBridge.available-layouts;
|
||||
current-index <=> SettingsBridge.selected-layout-index;
|
||||
selected(value) => { SettingsBridge.change-layout(self.current-index); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OTA Firmware Update
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
|
|
@ -92,69 +122,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,399 +0,0 @@
|
|||
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, ToolsBridge, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== MATRIX TEST =====================
|
||||
Rectangle {
|
||||
background: Theme.bg-secondary;
|
||||
border-radius: 8px;
|
||||
|
||||
VerticalLayout {
|
||||
padding: 16px;
|
||||
spacing: 10px;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 12px;
|
||||
|
||||
Text {
|
||||
text: "Matrix Test";
|
||||
color: Theme.accent-cyan;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
DarkButton {
|
||||
text: ToolsBridge.matrix-test-active ? "Stop Test" : "Start Test";
|
||||
primary: !ToolsBridge.matrix-test-active;
|
||||
enabled: AppState.connection == ConnectionState.connected;
|
||||
clicked => { ToolsBridge.toggle-matrix-test(); }
|
||||
}
|
||||
}
|
||||
|
||||
if ToolsBridge.matrix-test-status != "" : Text {
|
||||
text: ToolsBridge.matrix-test-status;
|
||||
color: ToolsBridge.matrix-test-active ? Theme.accent-green : Theme.fg-secondary;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
// Keyboard render for matrix test
|
||||
matrix-area := Rectangle {
|
||||
height: 260px;
|
||||
background: Theme.bg-surface;
|
||||
border-radius: 8px;
|
||||
clip: true;
|
||||
|
||||
property <float> scale-x: self.width / ToolsBridge.matrix-content-width;
|
||||
property <float> scale-y: self.height / ToolsBridge.matrix-content-height;
|
||||
property <float> scale: min(scale-x, scale-y) * 0.95;
|
||||
property <length> offset-x: (self.width - ToolsBridge.matrix-content-width * scale) / 2;
|
||||
property <length> offset-y: (self.height - ToolsBridge.matrix-content-height * scale) / 2;
|
||||
|
||||
if ToolsBridge.matrix-keycaps.length == 0 : Text {
|
||||
text: "Press \"Start Test\" to begin";
|
||||
color: Theme.fg-secondary;
|
||||
font-size: 13px;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
for keycap[idx] in ToolsBridge.matrix-keycaps : Rectangle {
|
||||
x: matrix-area.offset-x + keycap.x * matrix-area.scale;
|
||||
y: matrix-area.offset-y + keycap.y * matrix-area.scale;
|
||||
width: keycap.w * matrix-area.scale;
|
||||
height: keycap.h * matrix-area.scale;
|
||||
background: transparent;
|
||||
|
||||
Rectangle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: keycap.color;
|
||||
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, 10px * matrix-area.scale);
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 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: ["full (0x0)", "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