Compare commits

..

10 commits

Author SHA1 Message Date
Mae PUGIN
35950661ff Add Nix flake for building KeSp_controller 2026-04-10 11:50:12 +02:00
Mae PUGIN
9c6f069bf1 feat: Matrix test tool — live key press visualization in Tools tab
Sends MATRIX_TEST (0xB0) toggle, polls unsolicited KR events.
3-state colors: grey (untouched), green (pressed), purple (was activated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:05:36 +02:00
Mae PUGIN
88ab57bebe feat: Move language combobox to Layers tab, update key selector on layout change
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:30:18 +02:00
Mae PUGIN
67a883dd0b feat: Add full flash (0x0) option to flasher offset combobox
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:20:03 +02:00
Mae PUGIN
1420bbcc74 fix: Flasher otadata erase timeout — skip flash_end between sequences
The ROM accepts consecutive flash_begin calls without an intervening
flash_end, so we no longer end+re-sync between the firmware write and
the otadata erase. Single flash_end after both sequences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:38:29 +02:00
Mae PUGIN
3e54361d0d refactor: Modularize main.rs (2478 → 58 lines), flat architecture
Split monolithic main.rs into domain modules:
- context.rs: AppContext shared state, BgMsg enum, serial_spawn helper
- models.rs: UI model builders, keycap labels, key entries, layout preview
- config.rs: keyboard config export/import via binary protocol
- dispatch.rs: BgMsg handler + WPM/layout timers
- keymap.rs: key selection, layer switch/rename, heatmap toggle
- key_selector.rs: dispatch_keycode router, apply_keycode, hex/MT/LT
- macros.rs: macro CRUD, shortcut presets, step builder
- advanced.rs: combos, KO, leaders, tap dance, BT, tama, autoshift
- settings.rs: OTA flash, config backup, keyboard layout
- flasher.rs: ESP32 bootloader flash
- layout.rs: layout JSON preview, load/export
- connection.rs: serial connect/disconnect, tab auto-refresh
- stats.rs: stats refresh

Rename logic/ → protocol/ with cleaner file names.
Remove unused original-src/ directory.
Fix DarkLineEdit double styling, add rename popup, macro shortcuts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:02:22 +02:00
Mae PUGIN
b5023b8f02 fix: Macro name off-by-one — M0 displayed as M1
decode_keycode used (raw >> 8) - 0x14 but macro codes start at 0x15,
so M0 (0x1500) decoded to index 1 instead of 0. Fixed to - 0x15.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:51:43 +02:00
Mae PUGIN
d1d10b7d73 feat: Binary protocol v2, config import/export, Tools tab, new layout format
- Migrate all serial commands from ASCII text to binary KS/KR frame protocol
  (SETLAYER, TD_LIST, COMBO_LIST, LEADER_LIST, KO_LIST, etc.)
- Add config import/export as JSON (keymaps, tap dances, combos, KO, leaders, macros)
- Merge Flash + Layout Preview into single Tools tab
- Replace WPF tree layout JSON format with flat groups+keys format:
  - Top-level "keys" for absolute positioning (thumbs, isolated keys)
  - "groups" with x/y/r transform, keys inside use local coordinates
  - Coordinates in units (1u = 50px), w/h default 1u, r default 0
- Layout auto-refresh (5s timer) for live preview while editing externally
- Pretty-print JSON in layout preview and export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:30:04 +02:00
Mae PUGIN
88b51bd399 docs: Add README, LICENSE (GPL-3.0), prepare for public release
- README with features, build instructions, usage
- GPL-3.0 license (compatible with Slint GPLv3 tier)
- Updated Cargo.toml with license, description, repository
- Cleaned up .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:50:39 +02:00
Mae PUGIN
efd1e8ebf4 fix: Remove macOS Intel target, fix CI
macOS x86_64 not needed (all modern Macs are ARM).
3 targets: Linux x86_64, Windows x86_64, macOS ARM64.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:39:52 +02:00
59 changed files with 3854 additions and 7333 deletions

View file

@ -16,9 +16,6 @@ jobs:
- os: windows-latest - os: windows-latest
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
name: KeSp_controller-windows-x86_64.exe name: KeSp_controller-windows-x86_64.exe
- os: macos-13
target: x86_64-apple-darwin
name: KeSp_controller-macos-intel
- os: macos-latest - os: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
name: KeSp_controller-macos-arm64 name: KeSp_controller-macos-arm64
@ -66,5 +63,4 @@ jobs:
files: | files: |
KeSp_controller-linux-x86_64/KeSp_controller-linux-x86_64 KeSp_controller-linux-x86_64/KeSp_controller-linux-x86_64
KeSp_controller-windows-x86_64.exe/KeSp_controller-windows-x86_64.exe 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 KeSp_controller-macos-arm64/KeSp_controller-macos-arm64

4
.gitignore vendored
View file

@ -1 +1,5 @@
/target /target
*.swp
*.swo
*~
.DS_Store

View file

@ -2,6 +2,9 @@
name = "KeSp_controller" name = "KeSp_controller"
version = "1.0.0" version = "1.0.0"
edition = "2021" edition = "2021"
license = "GPL-3.0"
description = "Cross-platform configurator for the KeSp split ergonomic keyboard"
repository = "https://github.com/mornepousse/KeSp_controller"
[dependencies] [dependencies]
slint = "1" slint = "1"

22
LICENSE Normal file
View file

@ -0,0 +1,22 @@
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 Normal file
View file

@ -0,0 +1,63 @@
# 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.
![License: GPL-3.0](https://img.shields.io/badge/License-GPLv3-blue.svg)
## 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/)

View file

@ -1,260 +1,133 @@
{ {
"Group": { "name": "KaSe V2 Debug",
"Children": [ "rows": 5,
{ "cols": 13,
"Line": { "keys": [
"Orientation": "Horizontal", { "row": 0, "col": 6, "x": 6.48, "y": 3.4, "r": 10 },
"Children": [ { "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 },
"Group": { { "row": 4, "col": 4, "x": 4.6, "y": 5.0, "r": 20 },
"Children": [ { "row": 4, "col": 5, "x": 5.6, "y": 5.6, "r": 40 },
{ { "row": 4, "col": 6, "x": 6.4, "y": 6.3, "r": 40 },
"Line": { { "row": 3, "col": 6, "x": 7.66, "y": 6.3, "r": -40 },
"Orientation": "Horizontal", { "row": 4, "col": 7, "x": 8.49, "y": 5.61, "r": -40 },
"Children": [ { "row": 4, "col": 8, "x": 9.46, "y": 5.0, "r": -20 },
{ { "row": 4, "col": 9, "x": 10.56, "y": 4.7, "r": -10 },
"Line": { { "row": 4, "col": 10, "x": 11.76, "y": 4.7, "w": 1.2 },
"Children": [ { "row": 2, "col": 6, "x": 7.96, "y": 3.4, "r": -10 }
{ "Keycap": { "Column": 0, "Row": 4 } }, ],
{ "Keycap": { "Column": 0, "Row": 0 } }, "groups": [
{ "Keycap": { "Column": 0, "Row": 1 } }, {
{ "Keycap": { "Column": 0, "Row": 2 } }, "x": 0, "y": 0,
{ "Keycap": { "Column": 0, "Row": 3 } } "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 },
"Line": { { "row": 3, "col": 0, "x": 0, "y": 4.32 }
"Children": [ ]
{ "Keycap": { "Column": 1, "Row": 4 } }, },
{ "Keycap": { "Column": 1, "Row": 0 } }, {
{ "Keycap": { "Column": 1, "Row": 1 } }, "x": 1.08, "y": 0,
{ "Keycap": { "Column": 1, "Row": 2 } }, "keys": [
{ "Keycap": { "Column": 1, "Row": 3 } } { "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 }
"Line": { ]
"Margin": "8,25,0,0", },
"RenderTransform": { "RotateTransform": { "Angle": 5 } }, {
"Children": [ "x": 2.46, "y": 0.5, "r": 5,
{ "Keycap": { "Column": 2, "Row": 0 } }, "keys": [
{ "Keycap": { "Column": 2, "Row": 1 } }, { "row": 0, "col": 2, "x": 0, "y": 0 },
{ "Keycap": { "Column": 2, "Row": 2 } }, { "row": 1, "col": 2, "x": 0, "y": 1.08 },
{ "Keycap": { "Column": 2, "Row": 3 } } { "row": 2, "col": 2, "x": 0, "y": 2.16 },
] { "row": 3, "col": 2, "x": 0, "y": 3.24 }
} ]
}, },
{ {
"Line": { "x": 3.84, "y": 0.1, "r": 10,
"Margin": "10,5,0,0", "keys": [
"RenderTransform": { "RotateTransform": { "Angle": 10 } }, { "row": 0, "col": 3, "x": 0, "y": 0 },
"Children": [ { "row": 1, "col": 3, "x": 0, "y": 1.08 },
{ "Keycap": { "Column": 3, "Row": 0 } }, { "row": 2, "col": 3, "x": 0, "y": 2.16 },
{ "Keycap": { "Column": 3, "Row": 1 } }, { "row": 3, "col": 3, "x": 0, "y": 3.24 }
{ "Keycap": { "Column": 3, "Row": 2 } }, ]
{ "Keycap": { "Column": 3, "Row": 3 } } },
] {
} "x": 4.82, "y": 0.5, "r": 10,
}, "keys": [
{ { "row": 0, "col": 4, "x": 0, "y": 0 },
"Line": { { "row": 1, "col": 4, "x": 0, "y": 1.08 },
"Margin": "0,25,0,0", { "row": 2, "col": 4, "x": 0, "y": 2.16 },
"RenderTransform": { "RotateTransform": { "Angle": 10 } }, { "row": 3, "col": 4, "x": 0, "y": 3.24 }
"Children": [ ]
{ "Keycap": { "Column": 4, "Row": 0 } }, },
{ "Keycap": { "Column": 4, "Row": 1 } }, {
{ "Keycap": { "Column": 4, "Row": 2 } }, "x": 5.8, "y": 0.76, "r": 10,
{ "Keycap": { "Column": 4, "Row": 3 } } "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 }
"Line": { ]
"Margin": "0,38,0,0", },
"RenderTransform": { "RotateTransform": { "Angle": 10 } }, {
"Children": [ "x": 8.54, "y": 0.76, "r": -10,
{ "Keycap": { "Column": 5, "Row": 0 } }, "keys": [
{ "Keycap": { "Column": 5, "Row": 1 } }, { "row": 0, "col": 7, "x": 0, "y": 0 },
{ "Keycap": { "Column": 5, "Row": 2 } }, { "row": 1, "col": 7, "x": 0, "y": 1.08 },
{ "Keycap": { "Column": 5, "Row": 3 } } { "row": 2, "col": 7, "x": 0, "y": 2.16 },
] { "row": 3, "col": 7, "x": 0, "y": 3.24 }
} ]
}, },
{ {
"Keycap": { "x": 9.62, "y": 0.5, "r": -10,
"Column": 6, "keys": [
"Row": 0, { "row": 0, "col": 8, "x": 0, "y": 0 },
"Margin": "-8,170,0,0", { "row": 1, "col": 8, "x": 0, "y": 1.08 },
"RenderTransform": { "RotateTransform": { "Angle": 10 } } { "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,
{ "Keycap": { "Column": 2, "Row": 4, "Width": 60, "Margin": "110,228,0,0" } }, "keys": [
{ { "row": 0, "col": 9, "x": 0, "y": 0 },
"Keycap": { { "row": 1, "col": 9, "x": 0, "y": 1.08 },
"Column": 3, { "row": 2, "col": 9, "x": 0, "y": 2.16 },
"Row": 4, { "row": 3, "col": 9, "x": 0, "y": 3.24 }
"Margin": "174,225,0,0", ]
"RenderTransform": { "RotateTransform": { "Angle": 10 } } },
} {
}, "x": 11.98, "y": 0.4, "r": -5,
{ "keys": [
"Keycap": { { "row": 0, "col": 10, "x": 0, "y": 0 },
"Column": 4, { "row": 1, "col": 10, "x": 0, "y": 1.08 },
"Row": 4, { "row": 2, "col": 10, "x": 0, "y": 2.16 },
"Margin": "228,240,0,0", { "row": 3, "col": 10, "x": 0, "y": 3.24 }
"RenderTransform": { "RotateTransform": { "Angle": 20 } } ]
} },
}, {
{ "x": 13.36, "y": 0,
"Keycap": { "keys": [
"Column": 5, { "row": 4, "col": 11, "x": 0, "y": 0 },
"Row": 4, { "row": 0, "col": 11, "x": 0, "y": 1.08 },
"Margin": "280,270,0,0", { "row": 1, "col": 11, "x": 0, "y": 2.16 },
"RenderTransform": { "RotateTransform": { "Angle": 40 } } { "row": 2, "col": 11, "x": 0, "y": 3.24 },
} { "row": 3, "col": 11, "x": 0, "y": 4.32 }
}, ]
{ },
"Keycap": { {
"Column": 6, "x": 14.44, "y": 0,
"Row": 4, "keys": [
"Margin": "320,305,0,0", { "row": 4, "col": 12, "x": 0, "y": 0 },
"RenderTransform": { "RotateTransform": { "Angle": 40 } } { "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": { }
"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 Normal file
View file

@ -0,0 +1,56 @@
{
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 ];
};
});
}

View file

@ -1,501 +0,0 @@
/// 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);
}
}

View file

@ -1,390 +0,0 @@
/// 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()
}

View file

@ -1,286 +0,0 @@
use serde_json::Value;
/// A keycap with computed absolute position.
#[derive(Clone, Debug, PartialEq)]
pub struct KeycapPos {
pub row: usize,
pub col: usize,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub angle: f32, // degrees
}
const KEY_SIZE: f32 = 50.0;
const KEY_GAP: f32 = 4.0;
/// Parse a layout JSON string into absolute key positions.
pub fn parse_json(json: &str) -> Result<Vec<KeycapPos>, String> {
let val: Value = serde_json::from_str(json)
.map_err(|e| format!("Invalid layout JSON: {}", e))?;
let mut keys = Vec::new();
walk(&val, 0.0, 0.0, 0.0, &mut keys);
if keys.is_empty() {
return Err("No keys found in layout".into());
}
Ok(keys)
}
/// Default layout embedded at compile time.
pub fn default_layout() -> Vec<KeycapPos> {
let json = include_str!("../default.json");
parse_json(json).unwrap_or_default()
}
fn walk(node: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
let obj = match node.as_object() {
Some(o) => o,
None => return,
};
for (key, val) in obj {
let key_str = key.as_str();
match key_str {
"Group" => walk_group(val, ox, oy, parent_angle, out),
"Line" => walk_line(val, ox, oy, parent_angle, out),
"Keycap" => walk_keycap(val, ox, oy, parent_angle, out),
_ => {}
}
}
}
fn parse_margin(val: &Value) -> (f32, f32, f32, f32) {
let as_str = val.as_str();
if let Some(s) = as_str {
let split = s.split(',');
let parts: Vec<f32> = split
.filter_map(|p| {
let trimmed = p.trim();
let parsed = trimmed.parse().ok();
parsed
})
.collect();
let has_four_parts = parts.len() == 4;
if has_four_parts {
return (parts[0], parts[1], parts[2], parts[3]);
}
}
(0.0, 0.0, 0.0, 0.0)
}
fn parse_angle(val: &Value) -> f32 {
let rotate_transform = val.get("RotateTransform");
let angle_val = rotate_transform
.and_then(|rt| rt.get("Angle"));
let angle_f64 = angle_val
.and_then(|a| a.as_f64());
let angle = angle_f64.unwrap_or(0.0) as f32;
angle
}
fn walk_group(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
let obj = match val.as_object() {
Some(o) => o,
None => return,
};
let margin_val = obj.get("Margin");
let (ml, mt, _, _) = margin_val
.map(parse_margin)
.unwrap_or_default();
let transform_val = obj.get("RenderTransform");
let angle = transform_val
.map(parse_angle)
.unwrap_or(0.0);
let gx = ox + ml;
let gy = oy + mt;
let children_val = obj.get("Children");
let children_array = children_val
.and_then(|c| c.as_array());
if let Some(children) = children_array {
let combined_angle = parent_angle + angle;
for child in children {
walk(child, gx, gy, combined_angle, out);
}
}
}
fn walk_line(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
let obj = match val.as_object() {
Some(o) => o,
None => return,
};
let margin_val = obj.get("Margin");
let (ml, mt, _, _) = margin_val
.map(parse_margin)
.unwrap_or_default();
let transform_val = obj.get("RenderTransform");
let angle = transform_val
.map(parse_angle)
.unwrap_or(0.0);
let total_angle = parent_angle + angle;
let orientation_val = obj.get("Orientation");
let orientation_str = orientation_val
.and_then(|o| o.as_str())
.unwrap_or("Vertical");
let horiz = orientation_str == "Horizontal";
let lx = ox + ml;
let ly = oy + mt;
let rad = total_angle.to_radians();
let cos_a = rad.cos();
let sin_a = rad.sin();
let mut cursor = 0.0f32;
let children_val = obj.get("Children");
let children_array = children_val
.and_then(|c| c.as_array());
if let Some(children) = children_array {
for child in children {
let (cx, cy) = if horiz {
let x = lx + cursor * cos_a;
let y = ly + cursor * sin_a;
(x, y)
} else {
let x = lx - cursor * sin_a;
let y = ly + cursor * cos_a;
(x, y)
};
let child_size = measure(child, horiz);
walk(child, cx, cy, total_angle, out);
cursor += child_size;
}
}
}
/// Measure a child's extent along the parent's main axis.
fn measure(node: &Value, horiz: bool) -> f32 {
let obj = match node.as_object() {
Some(o) => o,
None => return 0.0,
};
for (key, val) in obj {
let key_str = key.as_str();
match key_str {
"Keycap" => {
let width_val = val.get("Width");
let width_f64 = width_val
.and_then(|v| v.as_f64());
let w = width_f64.unwrap_or(KEY_SIZE as f64) as f32;
let extent = if horiz {
w + KEY_GAP
} else {
KEY_SIZE + KEY_GAP
};
return extent;
}
"Line" => {
let sub = match val.as_object() {
Some(o) => o,
None => return 0.0,
};
let sub_orientation = sub.get("Orientation");
let sub_orient_str = sub_orientation
.and_then(|o| o.as_str())
.unwrap_or("Vertical");
let sub_horiz = sub_orient_str == "Horizontal";
let sub_children_val = sub.get("Children");
let sub_children_array = sub_children_val
.and_then(|c| c.as_array());
let children = sub_children_array
.map(|a| a.as_slice())
.unwrap_or(&[]);
let same_direction = sub_horiz == horiz;
let content: f32 = if same_direction {
// Same direction: sum
children
.iter()
.map(|c| measure(c, sub_horiz))
.sum()
} else {
// Cross direction: max
children
.iter()
.map(|c| measure(c, horiz))
.fold(0.0f32, f32::max)
};
return content;
}
"Group" => {
let sub = match val.as_object() {
Some(o) => o,
None => return 0.0,
};
let sub_children_val = sub.get("Children");
let sub_children_array = sub_children_val
.and_then(|c| c.as_array());
let children = sub_children_array
.map(|a| a.as_slice())
.unwrap_or(&[]);
let max_extent = children
.iter()
.map(|c| measure(c, horiz))
.fold(0.0f32, f32::max);
return max_extent;
}
_ => {}
}
}
0.0
}
fn walk_keycap(val: &Value, ox: f32, oy: f32, parent_angle: f32, out: &mut Vec<KeycapPos>) {
let obj = match val.as_object() {
Some(o) => o,
None => return,
};
let col_val = obj.get("Column");
let col_u64 = col_val
.and_then(|v| v.as_u64());
let col = col_u64.unwrap_or(0) as usize;
let row_val = obj.get("Row");
let row_u64 = row_val
.and_then(|v| v.as_u64());
let row = row_u64.unwrap_or(0) as usize;
let width_val = obj.get("Width");
let width_f64 = width_val
.and_then(|v| v.as_f64());
let w = width_f64.unwrap_or(KEY_SIZE as f64) as f32;
let margin_val = obj.get("Margin");
let (ml, mt, _, _) = margin_val
.map(parse_margin)
.unwrap_or_default();
let transform_val = obj.get("RenderTransform");
let angle = transform_val
.map(parse_angle)
.unwrap_or(0.0);
let total_angle = parent_angle + angle;
out.push(KeycapPos {
row,
col,
x: ox + ml,
y: oy + mt,
w,
h: KEY_SIZE,
angle: total_angle,
});
}

View file

@ -1,900 +0,0 @@
/// 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)
}

View file

@ -1,559 +0,0 @@
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
}

View file

@ -1,335 +0,0 @@
/// Stats analysis: hand balance, finger load, row usage, top keys, bigrams.
/// Transforms raw heatmap data into structured analysis for the stats tab.
use crate::keycode;
use crate::parsers::ROWS;
/// Which hand a column belongs to.
#[derive(Clone, Copy, PartialEq)]
pub enum Hand {
Left,
Right,
}
/// Which finger a column belongs to.
#[derive(Clone, Copy, PartialEq)]
pub enum Finger {
Pinky,
Ring,
Middle,
Index,
Thumb,
}
/// Row names for the 5 rows.
const ROW_NAMES: [&str; 5] = ["Number", "Upper", "Home", "Lower", "Thumb"];
/// Finger names (French).
const FINGER_NAMES: [&str; 5] = ["Pinky", "Ring", "Middle", "Index", "Thumb"];
/// Map column index → (Hand, Finger).
/// KaSe layout: cols 0-5 = left hand, cols 6 (gap), cols 7-12 = right hand.
fn col_to_hand_finger(col: usize) -> (Hand, Finger) {
match col {
0 => (Hand::Left, Finger::Pinky),
1 => (Hand::Left, Finger::Ring),
2 => (Hand::Left, Finger::Middle),
3 => (Hand::Left, Finger::Index),
4 => (Hand::Left, Finger::Index), // inner column, still index
5 => (Hand::Left, Finger::Thumb),
6 => (Hand::Left, Finger::Thumb), // center / gap
7 => (Hand::Right, Finger::Thumb),
8 => (Hand::Right, Finger::Index),
9 => (Hand::Right, Finger::Index), // inner column
10 => (Hand::Right, Finger::Middle),
11 => (Hand::Right, Finger::Ring),
12 => (Hand::Right, Finger::Pinky),
_ => (Hand::Left, Finger::Pinky),
}
}
/// Hand balance result.
#[allow(dead_code)]
pub struct HandBalance {
pub left_count: u32,
pub right_count: u32,
pub total: u32,
pub left_pct: f32,
pub right_pct: f32,
}
/// Finger load for one finger.
#[allow(dead_code)]
pub struct FingerLoad {
pub name: String,
pub hand: Hand,
pub count: u32,
pub pct: f32,
}
/// Row usage for one row.
#[allow(dead_code)]
pub struct RowUsage {
pub name: String,
pub row: usize,
pub count: u32,
pub pct: f32,
}
/// A key in the top keys ranking.
#[allow(dead_code)]
pub struct TopKey {
pub name: String,
pub finger: String,
pub count: u32,
pub pct: f32,
}
/// Compute hand balance from heatmap data.
pub fn hand_balance(heatmap: &[Vec<u32>]) -> HandBalance {
let mut left: u32 = 0;
let mut right: u32 = 0;
for row in heatmap {
for (c, &count) in row.iter().enumerate() {
let (hand, _) = col_to_hand_finger(c);
match hand {
Hand::Left => left += count,
Hand::Right => right += count,
}
}
}
let total = left + right;
let left_pct = if total > 0 { left as f32 / total as f32 * 100.0 } else { 0.0 };
let right_pct = if total > 0 { right as f32 / total as f32 * 100.0 } else { 0.0 };
HandBalance { left_count: left, right_count: right, total, left_pct, right_pct }
}
/// Compute finger load (10 fingers: 5 left + 5 right).
pub fn finger_load(heatmap: &[Vec<u32>]) -> Vec<FingerLoad> {
let mut counts = [[0u32; 5]; 2]; // [hand][finger]
for row in heatmap {
for (c, &count) in row.iter().enumerate() {
let (hand, finger) = col_to_hand_finger(c);
let hi = if hand == Hand::Left { 0 } else { 1 };
let fi = match finger {
Finger::Pinky => 0,
Finger::Ring => 1,
Finger::Middle => 2,
Finger::Index => 3,
Finger::Thumb => 4,
};
counts[hi][fi] += count;
}
}
let total: u32 = counts[0].iter().sum::<u32>() + counts[1].iter().sum::<u32>();
let mut result = Vec::with_capacity(10);
// Left hand fingers
for fi in 0..5 {
let count = counts[0][fi];
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
let name = format!("{} L", FINGER_NAMES[fi]);
result.push(FingerLoad { name, hand: Hand::Left, count, pct });
}
// Right hand fingers
for fi in 0..5 {
let count = counts[1][fi];
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
let name = format!("{} R", FINGER_NAMES[fi]);
result.push(FingerLoad { name, hand: Hand::Right, count, pct });
}
result
}
/// Compute row usage.
pub fn row_usage(heatmap: &[Vec<u32>]) -> Vec<RowUsage> {
let mut row_counts = [0u32; ROWS];
for (r, row) in heatmap.iter().enumerate() {
if r >= ROWS { break; }
let row_sum: u32 = row.iter().sum();
row_counts[r] = row_sum;
}
let total: u32 = row_counts.iter().sum();
let mut result = Vec::with_capacity(ROWS);
for r in 0..ROWS {
let count = row_counts[r];
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
let name = ROW_NAMES[r].to_string();
result.push(RowUsage { name, row: r, count, pct });
}
result
}
/// Compute top N keys by press count.
pub fn top_keys(heatmap: &[Vec<u32>], keymap: &[Vec<u16>], n: usize) -> Vec<TopKey> {
let mut all_keys: Vec<(u32, usize, usize)> = Vec::new(); // (count, row, col)
for (r, row) in heatmap.iter().enumerate() {
for (c, &count) in row.iter().enumerate() {
if count > 0 {
all_keys.push((count, r, c));
}
}
}
all_keys.sort_by(|a, b| b.0.cmp(&a.0));
all_keys.truncate(n);
let total: u32 = heatmap.iter().flat_map(|r| r.iter()).sum();
let mut result = Vec::with_capacity(n);
for (count, r, c) in all_keys {
let code = keymap.get(r).and_then(|row| row.get(c)).copied().unwrap_or(0);
let name = keycode::decode_keycode(code);
let (hand, finger) = col_to_hand_finger(c);
let hand_str = if hand == Hand::Left { "L" } else { "R" };
let finger_str = match finger {
Finger::Pinky => "Pinky",
Finger::Ring => "Ring",
Finger::Middle => "Middle",
Finger::Index => "Index",
Finger::Thumb => "Thumb",
};
let finger_label = format!("{} {}", finger_str, hand_str);
let pct = if total > 0 { count as f32 / total as f32 * 100.0 } else { 0.0 };
result.push(TopKey { name, finger: finger_label, count, pct });
}
result
}
/// Find keys that have never been pressed (count = 0, keycode != 0).
pub fn dead_keys(heatmap: &[Vec<u32>], keymap: &[Vec<u16>]) -> Vec<String> {
let mut result = Vec::new();
for (r, row) in heatmap.iter().enumerate() {
for (c, &count) in row.iter().enumerate() {
if count > 0 { continue; }
let code = keymap.get(r).and_then(|row| row.get(c)).copied().unwrap_or(0);
let is_mapped = code != 0;
if is_mapped {
let name = keycode::decode_keycode(code);
result.push(name);
}
}
}
result
}
// ==================== Bigram analysis ====================
/// A parsed bigram entry.
pub struct BigramEntry {
pub from_row: u8,
pub from_col: u8,
pub to_row: u8,
pub to_col: u8,
pub count: u32,
}
/// Bigram analysis results.
#[allow(dead_code)]
pub struct BigramAnalysis {
pub total: u32,
pub alt_hand: u32,
pub same_hand: u32,
pub sfb: u32,
pub alt_hand_pct: f32,
pub same_hand_pct: f32,
pub sfb_pct: f32,
}
/// Parse bigram text lines from firmware.
/// Format: " R2C3 -> R2C4 : 150"
pub fn parse_bigram_lines(lines: &[String]) -> Vec<BigramEntry> {
let mut entries = Vec::new();
for line in lines {
let trimmed = line.trim();
let has_arrow = trimmed.contains("->");
if !has_arrow { continue; }
let parts: Vec<&str> = trimmed.split("->").collect();
if parts.len() != 2 { continue; }
let left = parts[0].trim();
let right_and_count = parts[1].trim();
let right_parts: Vec<&str> = right_and_count.split(':').collect();
if right_parts.len() != 2 { continue; }
let right = right_parts[0].trim();
let count_str = right_parts[1].trim();
let from = parse_rc(left);
let to = parse_rc(right);
let count: u32 = count_str.parse().unwrap_or(0);
if let (Some((fr, fc)), Some((tr, tc))) = (from, to) {
entries.push(BigramEntry {
from_row: fr, from_col: fc,
to_row: tr, to_col: tc,
count,
});
}
}
entries
}
/// Parse "R2C3" into (row, col).
fn parse_rc(s: &str) -> Option<(u8, u8)> {
let s = s.trim();
let r_pos = s.find('R')?;
let c_pos = s.find('C')?;
if c_pos <= r_pos { return None; }
let row_str = &s[r_pos + 1..c_pos];
let col_str = &s[c_pos + 1..];
let row: u8 = row_str.parse().ok()?;
let col: u8 = col_str.parse().ok()?;
Some((row, col))
}
/// Analyze bigram entries for hand alternation and SFB.
pub fn analyze_bigrams(entries: &[BigramEntry]) -> BigramAnalysis {
let mut alt_hand: u32 = 0;
let mut same_hand: u32 = 0;
let mut sfb: u32 = 0;
let mut total: u32 = 0;
for entry in entries {
let (hand_from, finger_from) = col_to_hand_finger(entry.from_col as usize);
let (hand_to, finger_to) = col_to_hand_finger(entry.to_col as usize);
total += entry.count;
if hand_from != hand_to {
alt_hand += entry.count;
} else {
same_hand += entry.count;
if finger_from == finger_to {
sfb += entry.count;
}
}
}
let alt_hand_pct = if total > 0 { alt_hand as f32 / total as f32 * 100.0 } else { 0.0 };
let same_hand_pct = if total > 0 { same_hand as f32 / total as f32 * 100.0 } else { 0.0 };
let sfb_pct = if total > 0 { sfb as f32 / total as f32 * 100.0 } else { 0.0 };
BigramAnalysis {
total, alt_hand, same_hand, sfb,
alt_hand_pct, same_hand_pct, sfb_pct,
}
}

View file

@ -1,263 +0,0 @@
use super::BgResult;
/// Map a query tag to its binary command ID.
#[cfg(target_arch = "wasm32")]
use crate::binary_protocol as bp;
#[cfg(target_arch = "wasm32")]
fn tag_to_binary_cmd(tag: &str) -> Option<u8> {
match tag {
"td" => Some(bp::cmd::TD_LIST),
"combo" => Some(bp::cmd::COMBO_LIST),
"leader" => Some(bp::cmd::LEADER_LIST),
"ko" => Some(bp::cmd::KO_LIST),
"bt" => Some(bp::cmd::BT_QUERY),
"tama" => Some(bp::cmd::TAMA_QUERY),
"wpm" => Some(bp::cmd::WPM_QUERY),
"macros" => Some(bp::cmd::LIST_MACROS),
"keystats" => Some(bp::cmd::KEYSTATS_BIN),
"features" => Some(bp::cmd::FEATURES),
_ => None,
}
}
impl super::KaSeApp {
// ---- Background helpers ----
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn bg_query(&mut self, tag: &str, cmd: &str) {
self.busy = true;
let serial = self.serial.clone();
let tx = self.bg_tx.clone();
let tag = tag.to_string();
let cmd = cmd.to_string();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
let lines = ser.query_command(&cmd).unwrap_or_default();
let _ = tx.send(BgResult::TextLines(tag, lines));
});
}
#[cfg(target_arch = "wasm32")]
pub(super) fn bg_query(&mut self, tag: &str, cmd: &str) {
if self.web_busy.get() { return; }
self.busy = true;
self.web_busy.set(true);
let serial = self.serial.clone();
let tx = self.bg_tx.clone();
let web_busy = self.web_busy.clone();
let tag = tag.to_string();
let cmd = cmd.to_string();
wasm_bindgen_futures::spawn_local(async move {
let v2 = serial.borrow().v2;
let handles = serial.borrow().io_handles();
if let Ok((reader, writer)) = handles {
if let Some(cmd_id) = v2.then(|| tag_to_binary_cmd(&tag)).flatten() {
match crate::serial::send_binary(&reader, &writer, cmd_id, &[]).await {
Ok(resp) => {
let _ = tx.send(BgResult::BinaryPayload(tag, resp.payload));
}
Err(_) => {}
}
} else {
let result = crate::serial::query_command(&reader, &writer, &cmd).await;
match result {
Ok(lines) => {
let _ = tx.send(BgResult::TextLines(tag, lines));
}
Err(e) => {
if e == "timeout_refresh" {
serial.borrow_mut().refresh_reader();
}
}
}
}
}
web_busy.set(false);
});
}
/// Run multiple queries sequentially (avoids mutex contention).
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn bg_query_batch(&mut self, queries: &[(&str, &str)]) {
self.busy = true;
let serial = self.serial.clone();
let tx = self.bg_tx.clone();
let owned_queries: Vec<(String, String)> = queries
.iter()
.map(|(t, c)| (t.to_string(), c.to_string()))
.collect();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
for (tag, cmd) in owned_queries {
let lines = ser.query_command(&cmd).unwrap_or_default();
let _ = tx.send(BgResult::TextLines(tag, lines));
}
});
}
#[cfg(target_arch = "wasm32")]
pub(super) fn bg_query_batch(&mut self, queries: &[(&str, &str)]) {
if self.web_busy.get() { return; }
self.busy = true;
self.web_busy.set(true);
let serial = self.serial.clone();
let tx = self.bg_tx.clone();
let web_busy = self.web_busy.clone();
let owned: Vec<(String, String)> = queries
.iter()
.map(|(t, c)| (t.to_string(), c.to_string()))
.collect();
wasm_bindgen_futures::spawn_local(async move {
let v2 = serial.borrow().v2;
for (tag, text_cmd) in &owned {
let handles = serial.borrow().io_handles();
let (reader, writer) = match handles {
Ok(h) => h,
Err(_) => break,
};
if let Some(cmd_id) = v2.then(|| tag_to_binary_cmd(tag)).flatten() {
match crate::serial::send_binary(&reader, &writer, cmd_id, &[]).await {
Ok(resp) => {
let _ = tx.send(BgResult::BinaryPayload(tag.clone(), resp.payload));
}
Err(_) => {}
}
} else {
match crate::serial::query_command(&reader, &writer, text_cmd).await {
Ok(lines) => {
let _ = tx.send(BgResult::TextLines(tag.clone(), lines));
}
Err(e) => {
if e == "timeout_refresh" {
serial.borrow_mut().refresh_reader();
}
}
}
}
}
web_busy.set(false);
});
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn bg_send(&self, cmd: &str) {
let serial = self.serial.clone();
let cmd = cmd.to_string();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
let _ = ser.send_command(&cmd);
});
}
#[cfg(target_arch = "wasm32")]
pub(super) fn bg_send(&self, cmd: &str) {
let serial = self.serial.clone();
let cmd = cmd.to_string();
wasm_bindgen_futures::spawn_local(async move {
let handles = serial.borrow().io_handles();
if let Ok((_reader, writer)) = handles {
let _ = crate::serial::send_command(&writer, &cmd).await;
}
});
}
// ---- Data helpers ----
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn load_keymap(&mut self) {
self.busy = true;
let serial = self.serial.clone();
let tx = self.bg_tx.clone();
let layer = self.current_layer as u8;
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
match ser.get_keymap(layer) {
Ok(km) => { let _ = tx.send(BgResult::Keymap(km)); }
Err(e) => { let _ = tx.send(BgResult::Error(e)); }
}
});
}
#[cfg(target_arch = "wasm32")]
pub(super) fn load_keymap(&mut self) {
if self.web_busy.get() { return; }
self.busy = true;
self.web_busy.set(true);
let serial = self.serial.clone();
let tx = self.bg_tx.clone();
let web_busy = self.web_busy.clone();
let layer = self.current_layer as u8;
wasm_bindgen_futures::spawn_local(async move {
let ser = serial.borrow();
let v2 = ser.v2;
let handles = ser.io_handles();
drop(ser);
match handles {
Ok((reader, writer)) => {
match crate::serial::get_keymap(&reader, &writer, layer, v2).await {
Ok(km) => { let _ = tx.send(BgResult::Keymap(km)); }
Err(e) => { let _ = tx.send(BgResult::Error(e)); }
}
}
Err(e) => { let _ = tx.send(BgResult::Error(e)); }
}
web_busy.set(false);
});
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn load_layer_names(&self) {
let serial = self.serial.clone();
let tx = self.bg_tx.clone();
std::thread::spawn(move || {
let mut ser = serial.lock().unwrap_or_else(|p| p.into_inner());
match ser.get_layer_names() {
Ok(names) if !names.is_empty() => {
let _ = tx.send(BgResult::LayerNames(names));
}
_ => {}
}
});
}
#[cfg(target_arch = "wasm32")]
pub(super) fn load_layer_names(&self) {
if self.web_busy.get() { return; }
self.web_busy.set(true);
let serial = self.serial.clone();
let tx = self.bg_tx.clone();
let web_busy = self.web_busy.clone();
wasm_bindgen_futures::spawn_local(async move {
let ser = serial.borrow();
let v2 = ser.v2;
let handles = ser.io_handles();
drop(ser);
if let Ok((reader, writer)) = handles {
match crate::serial::get_layer_names(&reader, &writer, v2).await {
Ok(names) if !names.is_empty() => {
let _ = tx.send(BgResult::LayerNames(names));
}
_ => {}
}
}
web_busy.set(false);
});
}
}

View file

@ -1,139 +0,0 @@
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();
}
}

View file

@ -1,218 +0,0 @@
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)
}
}

View file

@ -1,277 +0,0 @@
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 Normal file
View file

@ -0,0 +1,283 @@
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 Normal file
View file

@ -0,0 +1,211 @@
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(),
))
}
}

155
src/connection.rs Normal file
View file

@ -0,0 +1,155 @@
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));
});
}
_ => {}
}
});
}
}

81
src/context.rs Normal file
View file

@ -0,0 +1,81 @@
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 Normal file
View file

@ -0,0 +1,417 @@
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, &current_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 Normal file
View file

@ -0,0 +1,119 @@
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())));
});
});
}

247
src/key_selector.rs Normal file
View file

@ -0,0 +1,247 @@
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 Normal file
View file

@ -0,0 +1,110 @@
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 Normal file
View file

@ -0,0 +1,113 @@
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));
}
});
}
});
});
}

View file

@ -1,259 +0,0 @@
/// 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))
}

View file

@ -1,286 +0,0 @@
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,
});
}

View file

@ -1,339 +0,0 @@
/// 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,
},
}
}

View file

@ -1,65 +0,0 @@
#![allow(dead_code)]
/// CDC protocol command helpers for KaSe keyboard firmware.
// Text-based query commands
pub const CMD_TAP_DANCE: &str = "TD?";
pub const CMD_COMBOS: &str = "COMBO?";
pub const CMD_LEADER: &str = "LEADER?";
pub const CMD_KEY_OVERRIDE: &str = "KO?";
pub const CMD_BT_STATUS: &str = "BT?";
pub const CMD_WPM: &str = "WPM?";
pub const CMD_TAMA: &str = "TAMA?";
pub const CMD_MACROS_TEXT: &str = "MACROS?";
pub const CMD_FEATURES: &str = "FEATURES?";
pub const CMD_KEYSTATS: &str = "KEYSTATS?";
pub const CMD_BIGRAMS: &str = "BIGRAMS?";
pub fn cmd_set_key(layer: u8, row: u8, col: u8, keycode: u16) -> String {
format!("SETKEY {},{},{},{:04X}", layer, row, col, keycode)
}
pub fn cmd_set_layer_name(layer: u8, name: &str) -> String {
format!("LAYOUTNAME{}:{}", layer, name)
}
pub fn cmd_bt_switch(slot: u8) -> String {
format!("BT SWITCH {}", slot)
}
pub fn cmd_trilayer(l1: u8, l2: u8, l3: u8) -> String {
format!("TRILAYER {},{},{}", l1, l2, l3)
}
pub fn cmd_macroseq(slot: u8, name: &str, steps: &str) -> String {
format!("MACROSEQ {};{};{}", slot, name, steps)
}
pub fn cmd_macro_del(slot: u8) -> String {
format!("MACRODEL {}", slot)
}
pub fn cmd_comboset(index: u8, r1: u8, c1: u8, r2: u8, c2: u8, result: u8) -> String {
format!("COMBOSET {};{},{},{},{},{:02X}", index, r1, c1, r2, c2, result)
}
pub fn cmd_combodel(index: u8) -> String {
format!("COMBODEL {}", index)
}
pub fn cmd_koset(index: u8, trig_key: u8, trig_mod: u8, res_key: u8, res_mod: u8) -> String {
format!("KOSET {};{:02X},{:02X},{:02X},{:02X}", index, trig_key, trig_mod, res_key, res_mod)
}
pub fn cmd_kodel(index: u8) -> String {
format!("KODEL {}", index)
}
pub fn cmd_leaderset(index: u8, sequence: &[u8], result: u8, result_mod: u8) -> String {
let seq_hex: Vec<String> = sequence.iter().map(|k| format!("{:02X}", k)).collect();
let seq_str = seq_hex.join(",");
format!("LEADERSET {};{};{:02X},{:02X}", index, seq_str, result, result_mod)
}
pub fn cmd_leaderdel(index: u8) -> String {
format!("LEADERDEL {}", index)
}

View file

@ -1,15 +0,0 @@
/// Serial communication module.
/// Dispatches to native (serialport crate) or web (WebSerial API)
/// depending on the target architecture.
#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(target_arch = "wasm32")]
mod web;
#[cfg(not(target_arch = "wasm32"))]
pub use native::*;
#[cfg(target_arch = "wasm32")]
pub use web::*;

View file

@ -1,118 +0,0 @@
/// 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 Normal file
View file

@ -0,0 +1,182 @@
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));
}
});
});
}
}

File diff suppressed because it is too large Load diff

270
src/models.rs Normal file
View file

@ -0,0 +1,270 @@
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(&current);
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));
}
}
}

View file

@ -31,8 +31,10 @@ pub mod cmd {
// Statistics // Statistics
pub const KEYSTATS_BIN: u8 = 0x40; pub const KEYSTATS_BIN: u8 = 0x40;
pub const KEYSTATS_TEXT: u8 = 0x41;
pub const KEYSTATS_RESET: u8 = 0x42; pub const KEYSTATS_RESET: u8 = 0x42;
pub const BIGRAMS_BIN: u8 = 0x43; pub const BIGRAMS_BIN: u8 = 0x43;
pub const BIGRAMS_TEXT: u8 = 0x44;
pub const BIGRAMS_RESET: u8 = 0x45; pub const BIGRAMS_RESET: u8 = 0x45;
// Tap Dance // Tap Dance
@ -64,7 +66,7 @@ pub mod cmd {
pub const KO_LIST: u8 = 0x92; pub const KO_LIST: u8 = 0x92;
pub const KO_DELETE: u8 = 0x93; pub const KO_DELETE: u8 = 0x93;
pub const WPM_QUERY: u8 = 0x94; pub const WPM_QUERY: u8 = 0x94;
pub const TRILAYER_SET: u8 = 0x94; pub const TRILAYER_SET: u8 = 0x95;
// Tamagotchi // Tamagotchi
pub const TAMA_QUERY: u8 = 0xA0; pub const TAMA_QUERY: u8 = 0xA0;
@ -76,6 +78,9 @@ pub mod cmd {
pub const TAMA_MEDICINE: u8 = 0xA6; pub const TAMA_MEDICINE: u8 = 0xA6;
pub const TAMA_SAVE: u8 = 0xA7; pub const TAMA_SAVE: u8 = 0xA7;
// Diagnostics
pub const MATRIX_TEST: u8 = 0xB0;
// OTA // OTA
pub const OTA_START: u8 = 0xF0; pub const OTA_START: u8 = 0xF0;
pub const OTA_DATA: u8 = 0xF1; pub const OTA_DATA: u8 = 0xF1;
@ -189,6 +194,32 @@ pub fn leader_set_payload(index: u8, sequence: &[u8], result: u8, result_mod: u8
payload 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. /// Parsed KR response.
#[derive(Debug)] #[derive(Debug)]
pub struct KrResponse { pub struct KrResponse {
@ -257,3 +288,21 @@ pub fn parse_kr(data: &[u8]) -> Result<(KrResponse, usize), String> {
let consumed = payload_end + 1 - pos; let consumed = payload_end + 1 - pos;
Ok((KrResponse { cmd, status, payload }, consumed)) 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
}

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

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

View file

@ -643,23 +643,17 @@ pub fn flash_firmware(
} }
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
// Step 8: FLASH_END // // Step 8: Erase otadata when flashing factory //
// //
// 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 // // If we just wrote to the factory partition, the bootloader might //
// still have otadata pointing to ota_0. We erase otadata to force // // still have otadata pointing to ota_0. We erase otadata to force //
// the bootloader to fall back to factory on next boot. // // 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 { if offset == 0x20000 {
send_progress(0.93, "Erasing otadata (force factory boot)...".into()); send_progress(0.92, "Erasing otadata (force factory boot)...".into());
const OTADATA_OFFSET: u32 = 0xF000; const OTADATA_OFFSET: u32 = 0xF000;
const OTADATA_SIZE: u32 = 0x2000; // 8 KB const OTADATA_SIZE: u32 = 0x2000; // 8 KB
flash_begin(&mut port, OTADATA_OFFSET, OTADATA_SIZE, FLASH_BLOCK_SIZE)?; flash_begin(&mut port, OTADATA_OFFSET, OTADATA_SIZE, FLASH_BLOCK_SIZE)?;
@ -668,9 +662,14 @@ pub fn flash_firmware(
for i in 0..otadata_blocks { for i in 0..otadata_blocks {
flash_data(&mut port, i, &empty_block)?; 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 // // Step 9: MD5 post-write verification //
// // // //

View file

@ -22,7 +22,7 @@ pub fn decode_keycode(raw: u16) -> String {
// --- MACRO: 0x1500..=0x2800, low byte == 0 --- // --- MACRO: 0x1500..=0x2800, low byte == 0 ---
if raw >= 0x1500 && raw <= 0x2800 && (raw & 0xFF) == 0 { if raw >= 0x1500 && raw <= 0x2800 && (raw & 0xFF) == 0 {
let idx = (raw >> 8) - 0x14; let idx = (raw >> 8) - 0x15;
return format!("M{idx}"); return format!("M{idx}");
} }

104
src/protocol/layout.rs Normal file
View file

@ -0,0 +1,104 @@
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()
}

View file

@ -1,5 +1,6 @@
#[allow(dead_code)] #[allow(dead_code)]
pub mod binary_protocol; pub mod binary;
pub mod config_io;
#[allow(dead_code)] #[allow(dead_code)]
pub mod flasher; pub mod flasher;
#[allow(dead_code)] #[allow(dead_code)]
@ -10,10 +11,10 @@ pub mod layout_remap;
#[allow(dead_code)] #[allow(dead_code)]
pub mod parsers; pub mod parsers;
#[allow(dead_code)] #[allow(dead_code)]
pub mod protocol; pub mod text_commands;
#[allow(dead_code)] #[allow(dead_code)]
pub mod serial; pub mod serial;
#[allow(dead_code)] #[allow(dead_code)]
pub mod settings; pub mod settings;
#[allow(dead_code)] #[allow(dead_code)]
pub mod stats_analyzer; pub mod stats;

View file

@ -3,8 +3,8 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::logic::binary_protocol::{self as bp, KrResponse}; use crate::protocol::binary::{self as bp, KrResponse};
use crate::logic::parsers::{ROWS, COLS}; use crate::protocol::parsers::{ROWS, COLS};
const BAUD_RATE: u32 = 115200; const BAUD_RATE: u32 = 115200;
const CONNECT_TIMEOUT_MS: u64 = 300; const CONNECT_TIMEOUT_MS: u64 = 300;

263
src/settings.rs Normal file
View file

@ -0,0 +1,263 @@
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 Normal file
View file

@ -0,0 +1,28 @@
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 Normal file
View file

@ -0,0 +1,101 @@
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));
}
}
}
}

View file

@ -1,4 +1,3 @@
import { LineEdit } from "std-widgets.slint";
import { Theme } from "../theme.slint"; import { Theme } from "../theme.slint";
export component DarkLineEdit inherits Rectangle { export component DarkLineEdit inherits Rectangle {
@ -9,15 +8,34 @@ export component DarkLineEdit inherits Rectangle {
forward-focus: inner; forward-focus: inner;
height: 28px; height: 28px;
min-width: 60px;
border-radius: 4px; border-radius: 4px;
border-width: 1px; border-width: 1px;
border-color: inner.has-focus ? Theme.accent-purple : Theme.button-border; border-color: inner.has-focus ? Theme.accent-purple : Theme.button-border;
background: Theme.button-bg; background: Theme.button-bg;
inner := LineEdit { // 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;
text <=> root.text; text <=> root.text;
placeholder-text: root.placeholder-text; color: Theme.fg-primary;
accepted(t) => { root.accepted(t); } font-size: 12px;
edited(t) => { root.edited(t); } vertical-alignment: center;
accepted => { root.accepted(root.text); }
edited => { root.edited(root.text); }
} }
} }

View file

@ -81,6 +81,12 @@ export global SettingsBridge {
in property <bool> ota-flashing: false; in property <bool> ota-flashing: false;
callback ota-browse(); callback ota-browse();
callback ota-start(); 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 ---- // ---- Stats ----
@ -247,6 +253,7 @@ export global MacroBridge {
callback save-macro(); // reads slot, name, steps from properties callback save-macro(); // reads slot, name, steps from properties
callback delete-macro(int); callback delete-macro(int);
callback add-delay-step(int); // delay in ms (50, 100, 200, 500) 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 remove-last-step();
callback clear-steps(); callback clear-steps();
} }
@ -257,7 +264,7 @@ export global FlasherBridge {
in property <[string]> prog-ports; in property <[string]> prog-ports;
in-out property <string> selected-prog-port: ""; in-out property <string> selected-prog-port: "";
in-out property <string> firmware-path: ""; in-out property <string> firmware-path: "";
in-out property <int> flash-offset-index: 0; // 0=factory(0x20000), 1=ota_0(0x220000) in-out property <int> flash-offset-index: 1; // 0=full(0x0), 1=factory(0x20000), 2=ota_0(0x220000)
in property <float> flash-progress: 0; in property <float> flash-progress: 0;
in property <string> flash-status: ""; in property <string> flash-status: "";
in property <bool> flashing: false; in property <bool> flashing: false;
@ -266,6 +273,34 @@ export global FlasherBridge {
callback flash(); 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 ---- // ---- Key Selector ----
export struct KeyEntry { export struct KeyEntry {

View file

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

View file

@ -91,7 +91,7 @@ export component TabFlasher inherits Rectangle {
} }
DarkComboBox { DarkComboBox {
model: ["factory (0x20000)", "ota_0 (0x220000)"]; model: ["full (0x0)", "factory (0x20000)", "ota_0 (0x220000)"];
current-index <=> FlasherBridge.flash-offset-index; current-index <=> FlasherBridge.flash-offset-index;
} }
} }

View file

@ -1,150 +1,199 @@
import { DarkLineEdit } from "../components/dark_line_edit.slint"; import { DarkLineEdit } from "../components/dark_line_edit.slint";
import { DarkComboBox } from "../components/dark_combo_box.slint";
import { Theme } from "../theme.slint"; import { Theme } from "../theme.slint";
import { DarkButton } from "../components/dark_button.slint"; import { DarkButton } from "../components/dark_button.slint";
import { KeymapBridge, AppState, ConnectionState } from "../globals.slint"; import { KeymapBridge, AppState, ConnectionState, SettingsBridge } from "../globals.slint";
import { KeyboardView } from "../components/keyboard_view.slint"; import { KeyboardView } from "../components/keyboard_view.slint";
export component TabKeymap inherits VerticalLayout { export component TabKeymap inherits Rectangle {
in-out property <bool> renaming: false; in-out property <bool> renaming: false;
padding: 8px; VerticalLayout {
spacing: 6px; padding: 8px;
// Main area: layers sidebar + keyboard
HorizontalLayout {
vertical-stretch: 1;
spacing: 6px; spacing: 6px;
// Layer sidebar (vertical) // Main area: layers sidebar + keyboard
VerticalLayout { HorizontalLayout {
width: 90px; vertical-stretch: 1;
spacing: 4px; spacing: 6px;
alignment: start;
Text { // Layer sidebar (vertical)
text: "Layers"; VerticalLayout {
color: Theme.fg-secondary; width: 90px;
font-size: 11px; spacing: 4px;
horizontal-alignment: center; alignment: start;
}
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 {
text: layer.name; text: "Layers";
color: Theme.fg-primary; color: Theme.fg-secondary;
font-size: 11px; font-size: 11px;
horizontal-alignment: center; 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; vertical-alignment: center;
} }
TouchArea { Rectangle { horizontal-stretch: 1; }
clicked => { KeymapBridge.switch-layer(layer.index); }
mouse-cursor: pointer; DarkButton {
} text: "Change Key...";
} clicked => {
} KeymapBridge.key-selector-open = true;
} }
}
}
}
}
// 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 { rename-input := DarkLineEdit {
max-width: 85px;
placeholder-text: "New name"; placeholder-text: "New name";
accepted(text) => { accepted(text) => {
KeymapBridge.rename-layer(KeymapBridge.active-layer, text); KeymapBridge.rename-layer(KeymapBridge.active-layer, text);
root.renaming = false; root.renaming = false;
} }
} }
DarkButton {
text: "Cancel";
clicked => { root.renaming = false; }
}
}
if !root.renaming && AppState.connection == ConnectionState.connected : Rectangle { HorizontalLayout {
height: 24px; spacing: 8px;
border-radius: 4px; alignment: center;
background: rename-ta.has-hover ? Theme.button-hover : Theme.button-bg;
Text { DarkButton {
text: "Rename"; text: "Cancel";
color: Theme.fg-secondary; clicked => { root.renaming = false; }
font-size: 10px; }
horizontal-alignment: center;
vertical-alignment: center;
}
rename-ta := TouchArea { DarkButton {
clicked => { root.renaming = true; } text: "Rename";
mouse-cursor: pointer; clicked => {
} KeymapBridge.rename-layer(KeymapBridge.active-layer, rename-input.text);
} root.renaming = false;
}
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;
} }
} }
} }

View file

@ -37,7 +37,9 @@ export component TabMacros inherits Rectangle {
HorizontalLayout { HorizontalLayout {
spacing: 8px; spacing: 8px;
alignment: start;
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; }
Text { text: "Name:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; } Text { text: "Name:"; color: Theme.fg-secondary; font-size: 12px; vertical-alignment: center; }
DarkLineEdit { DarkLineEdit {
@ -102,6 +104,23 @@ export component TabMacros inherits Rectangle {
DarkButton { text: "Clear"; clicked => { MacroBridge.clear-steps(); } } 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 // Save
HorizontalLayout { HorizontalLayout {
spacing: 8px; spacing: 8px;

View file

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

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

@ -0,0 +1,399 @@
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;
}
}
}
}
}
}