From 9c8267944ca7c7f15a478679b0e14e937ac496f3 Mon Sep 17 00:00:00 2001 From: Haobo Gu Date: Sat, 15 Aug 2026 17:42:58 +0800 Subject: [PATCH] feat: add KLE/Vial <-> [layout] converter and terminal preview The RMK docs already point at 'rmkit layout' for turning an existing KLE or Vial layout into a keyboard.toml's [layout] section. The conversion engine lives upstream in the rynk-kle crate (which round-trips its output through RMK's own layout builder, so the result is guaranteed to build); this adds the CLI over it: - 'rmkit layout convert ' emits the [layout] section, with -o to write a file and --to-vial for the reverse direction - 'rmkit layout show ' renders the physical layout as box-drawing art: shared borders merge into junctions, two-rect ISO/big-ass Enter keys draw as their union silhouette, encoders get rounded corners, and --variant picks one [[layout.variant]] - CLI tests with golden fixtures (corne, BAlice65, ANSI60 split backspace, raw KLE exports with and without row,col legends) A character grid cannot draw rotation, so rotated keys render axis-aligned with the angle printed inside. --- Cargo.lock | 255 ++++++++++++++++++++++++ Cargo.toml | 6 +- README.md | 35 ++++ src/args.rs | 35 ++++ src/layout_cmd.rs | 263 ++++++++++++++++++++++++ src/main.rs | 20 ++ src/render.rs | 307 +++++++++++++++++++++++++++++ tests/cli.rs | 183 +++++++++++++++++ tests/fixtures/BAlice65_vial.json | 85 ++++++++ tests/fixtures/BAlice65_vial.toml | 135 +++++++++++++ tests/fixtures/ansi60_splitbs.json | 35 ++++ tests/fixtures/ansi60_splitbs.toml | 26 +++ tests/fixtures/corne.json | 28 +++ tests/fixtures/corne.toml | 45 +++++ tests/fixtures/kle_export.json | 6 + tests/fixtures/kle_export.toml | 13 ++ tests/fixtures/kle_rowcol.json | 4 + tests/fixtures/kle_rowcol.toml | 11 ++ 18 files changed, 1490 insertions(+), 2 deletions(-) create mode 100644 src/layout_cmd.rs create mode 100644 src/render.rs create mode 100644 tests/cli.rs create mode 100644 tests/fixtures/BAlice65_vial.json create mode 100644 tests/fixtures/BAlice65_vial.toml create mode 100644 tests/fixtures/ansi60_splitbs.json create mode 100644 tests/fixtures/ansi60_splitbs.toml create mode 100644 tests/fixtures/corne.json create mode 100644 tests/fixtures/corne.toml create mode 100644 tests/fixtures/kle_export.json create mode 100644 tests/fixtures/kle_export.toml create mode 100644 tests/fixtures/kle_rowcol.json create mode 100644 tests/fixtures/kle_rowcol.toml diff --git a/Cargo.lock b/Cargo.lock index 79c951e..0cd4992 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,6 +125,17 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitfield-struct" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca6739863c590881f038d033a146c51ddae239186a4327014839fd864f44ed5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -434,6 +445,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossterm" version = "0.25.0" @@ -475,6 +492,18 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199f851bd3cb5004c09474252c7f74e7c047441ed0979bf3688a7106a13da952" +dependencies = [ + "num-traits", + "phf", + "serde", + "uncased", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -535,6 +564,26 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-sync" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async", + "futures-core", + "futures-sink", + "heapless", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -547,6 +596,21 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "embedded-io 0.7.1", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -583,6 +647,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -770,6 +840,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -800,6 +879,17 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "serde_core", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1103,6 +1193,18 @@ dependencies = [ "serde", ] +[[package]] +name = "kle-serial" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada2ce67a08613bce6d3f44eaa76f999bc9eb92f6513edeaf594e34ddfcbcfc4" +dependencies = [ + "csscolorparser", + "num-traits", + "rgb", + "serde", +] + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -1135,6 +1237,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "litemap" version = "0.8.3" @@ -1232,6 +1340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1358,6 +1467,51 @@ dependencies = [ "pest", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", + "uncased", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", + "uncased", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1379,9 +1533,21 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", + "postcard-derive", "serde", ] +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1565,6 +1731,12 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -1596,6 +1768,21 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "rmk-types" +version = "0.3.0" +source = "git+https://github.com/rmk-rs/rmk?rev=93726512f2fb383cdb5a419b09ff181c7c123b5f#93726512f2fb383cdb5a419b09ff181c7c123b5f" +dependencies = [ + "bitfield-struct", + "cobs", + "heapless", + "postcard", + "rmk-config", + "serde", + "strum", + "toml 1.1.4+spec-1.1.0", +] + [[package]] name = "rmkit" version = "0.0.21" @@ -1608,6 +1795,7 @@ dependencies = [ "inquire", "reqwest", "rmk-config", + "rynk-kle", "serde", "serde_derive", "serde_json", @@ -1688,6 +1876,37 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rynk" +version = "0.2.0" +source = "git+https://github.com/rmk-rs/rmk?rev=93726512f2fb383cdb5a419b09ff181c7c123b5f#93726512f2fb383cdb5a419b09ff181c7c123b5f" +dependencies = [ + "critical-section", + "embassy-futures", + "embassy-sync", + "embedded-io-async", + "heapless", + "log", + "postcard", + "rmk-config", + "rmk-types", + "serde", + "thiserror 2.0.20", +] + +[[package]] +name = "rynk-kle" +version = "0.2.0" +source = "git+https://github.com/rmk-rs/rmk?rev=93726512f2fb383cdb5a419b09ff181c7c123b5f#93726512f2fb383cdb5a419b09ff181c7c123b5f" +dependencies = [ + "kle-serial", + "rmk-config", + "rynk", + "serde", + "serde_json", + "toml 1.1.4+spec-1.1.0", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1879,6 +2098,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1913,6 +2138,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2311,6 +2557,15 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index ef2a6ff..0b73b62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,11 @@ description = "rmkit is a toolkit set for RMK keyboard firmware" license = "Apache-2.0" [dependencies] -# TODO: back to `version = "0.7"` once rmk-config 0.7.0 is on crates.io — a git -# dependency cannot be published. +# TODO: back to crates.io versions (rmk-config = "0.7", rynk-kle) once they are +# published — a git dependency cannot be published. rmk-config = { git = "https://github.com/rmk-rs/rmk", rev = "93726512f2fb383cdb5a419b09ff181c7c123b5f" } +# The KLE/Vial ↔ [layout] conversion engine; `rmkit layout` is CLI glue over it. +rynk-kle = { git = "https://github.com/rmk-rs/rmk", rev = "93726512f2fb383cdb5a419b09ff181c7c123b5f" } clap = { version = "4.5.23", features = ["derive", "string"] } toml = "0.9.8" serde = "1.0" diff --git a/README.md b/README.md index 6628593..e54173b 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,38 @@ Now rmkit can be used to generate RMK project directly from `keyboard.toml` and The available project template can be found at [rmk-template](https://github.com/rmk-rs/rmk-template) +## Layout tools + +`rmkit layout` works with the physical `[layout]` section of a `keyboard.toml`. The conversion engine is the [`rynk-kle`](https://github.com/rmk-rs/rmk/tree/main/rynk/rynk-kle) library crate (which also compiles to a wasm package for the web); this CLI wraps it. + +Convert a [KLE](http://www.keyboard-layout-editor.com/) JSON export or a [Vial](https://get.vial.today/) definition (`vial.json`) into RMK's `[layout]` — key positions, cap sizes, split gaps, rotation, ISO/L-shaped caps, encoders, and VIA layout options are all converted, and the result is validated against RMK's own layout builder. KLE carries no keycodes, so author the `[keymap]` yourself: + +```shell +rmkit layout convert path/to/vial.json -o layout.toml # vial.json → [layout] +rmkit layout convert path/to/kle_export.json # raw KLE "Download JSON" export too +rmkit layout convert --to-vial keyboard.toml # reverse: [layout] → vial.json +``` + +Render a physical layout as box-drawing art, to check the geometry in a terminal without flashing anything. The input can be a `keyboard.toml`, or a `vial.json` / raw KLE export directly (converted on the fly); `--variant` picks one `[[layout.variant]]`: + +```shell +$ rmkit layout show keyboard.toml +variant 'default': 17 keys, 4u × 5u + +┌─────┬─────┬─────┬─────┐ +│ 0,0 │ 0,1 │ 0,2 │ 0,3 │ +│ │ │ │ │ +├─────┼─────┼─────┼─────┤ +│ 1,0 │ 1,1 │ 1,2 │ │ +│ │ │ │ │ +├─────┼─────┼─────┤ 1,3 │ +│ 2,0 │ 2,1 │ 2,2 │ │ +│ │ │ │ │ +├─────┼─────┼─────┼─────┤ +│ 3,0 │ 3,1 │ 3,2 │ │ +│ │ │ │ │ +├─────┴─────┼─────┤ 3,3 │ +│ 4,0 │ 4,1 │ │ +│ │ │ │ +└───────────┴─────┴─────┘ +``` diff --git a/src/args.rs b/src/args.rs index 8ac988e..0f433bf 100644 --- a/src/args.rs +++ b/src/args.rs @@ -62,4 +62,39 @@ pub enum Commands { #[arg(long)] keyboard_toml_path: String, }, + /// Physical-layout tools: convert KLE/Vial layouts and preview [layout] geometry + Layout { + #[command(subcommand)] + command: LayoutCommands, + }, +} + +#[derive(Subcommand, Debug)] +pub enum LayoutCommands { + /// Convert a KLE JSON export or vial.json into an RMK [layout] section (or back with --to-vial) + Convert { + /// Path to a KLE JSON export or a vial.json (a keyboard.toml with --to-vial) + input: String, + + /// Write the result to FILE instead of stdout + #[arg(short, long)] + output: Option, + + /// Reverse: a keyboard.toml's [layout] → a minimal vial.json + #[arg(long)] + to_vial: bool, + + /// Skip the rmk-config round-trip check (forward only) + #[arg(long)] + no_validate: bool, + }, + /// Render a physical layout (keyboard.toml, vial.json, or KLE export) as box-drawing art + Show { + /// Path to a keyboard.toml (or bare [layout] snippet), a vial.json, or a raw KLE JSON export + input: String, + + /// Render only this [[layout.variant]] (default: all) + #[arg(long)] + variant: Option, + }, } diff --git a/src/layout_cmd.rs b/src/layout_cmd.rs new file mode 100644 index 0000000..782b998 --- /dev/null +++ b/src/layout_cmd.rs @@ -0,0 +1,263 @@ +//! `rmkit layout` — CLI over the `rynk-kle` conversion library. +//! +//! `convert` turns a physical keyboard layout — a raw +//! [KLE](http://keyboard-layout-editor.com) JSON export or a Vial keyboard +//! definition (`vial.json`) — into the `[layout]` section of an RMK/Rynk +//! `keyboard.toml`, or the reverse with `--to-vial`. Neither input carries +//! keycodes, so no `[keymap]` is emitted — author it yourself against the +//! generated map. +//! +//! `show` renders the physical layout as Unicode box-drawing text, for +//! checking the geometry in a terminal. It takes a keyboard.toml — or a +//! vial.json / raw KLE export directly, converting it on the fly. +//! +//! All parsing, conversion, and blob decoding live in `rynk_kle`; this module +//! only adds file IO, stderr reporting, and the terminal renderer. + +use rynk_kle::layout::{LayoutInfo, Variant}; +use serde_json::Value; + +use crate::render; + +/// `rmkit layout convert`: KLE export or vial.json → `[layout]` (or, with +/// `to_vial`, a keyboard.toml's `[layout]` → a minimal vial.json). +pub fn convert( + input: &str, + output: Option<&str>, + to_vial: bool, + validate: bool, +) -> Result<(), String> { + if to_vial { + let text = + std::fs::read_to_string(input).map_err(|e| format!("cannot read {input}: {e}"))?; + let vial = rynk_kle::keyboard_toml_to_vial(&text)?; + let out = format!( + "{}\n", + serde_json::to_string_pretty(&vial).map_err(|e| e.to_string())? + ); + match output { + Some(path) => { + std::fs::write(path, &out).map_err(|e| format!("cannot write {path}: {e}"))?; + eprintln!("wrote {path}"); + } + None => print!("{out}"), + } + return Ok(()); + } + + let text = std::fs::read_to_string(input).map_err(|e| format!("cannot read {input}: {e}"))?; + let root: Value = + serde_json::from_str(&text).map_err(|e| format!("invalid JSON in {input}: {e}"))?; + let generated = rynk_kle::convert_kle(&root)?; + + // Round-trip the generated [layout] through RMK's own builder. + let mut validation_error = None; + if validate { + match rynk_kle::decode_layout(&generated.layout_toml) { + Ok(info) => eprintln!( + "validation: OK ({} variant{} decoded)", + info.variants.len(), + if info.variants.len() == 1 { "" } else { "s" } + ), + Err(e) => validation_error = Some(e), + } + } + + for w in &generated.warnings { + eprintln!("warning: {w}"); + } + + let header = format!( + "# Generated from {input} by rmkit — review the geometry. The source\n\ + # carries no keycodes, so author the [keymap] yourself: its `keys` follow the\n\ + # map's key order, plus one [\"cw\", \"ccw\"] pair per encoder in `encoders`.\n\n" + ); + let out = header + &generated.layout_toml; + match output { + Some(path) => { + std::fs::write(path, &out).map_err(|e| format!("cannot write {path}: {e}"))?; + eprintln!("wrote {path}"); + } + None => print!("{out}"), + } + + if let Some(e) = validation_error { + return Err(format!( + "the generated layout did not pass rmk-config validation: {e}\n\ + (output was still written; please report this as a converter bug)" + )); + } + Ok(()) +} + +/// Any supported `show` input → decoded [`LayoutInfo`]. Content that parses as +/// JSON is a vial.json / raw KLE export and goes through the same conversion +/// `convert` performs; anything else is treated as keyboard.toml. +fn decode_input(input: &str, text: &str) -> Result { + match serde_json::from_str::(text) { + Ok(root) => { + let generated = rynk_kle::convert_kle(&root)?; + for w in &generated.warnings { + eprintln!("warning: {w}"); + } + rynk_kle::decode_layout(&generated.layout_toml) + } + // Not JSON but named like it: report the JSON error, not a TOML one. + Err(e) if input.ends_with(".json") => Err(format!("invalid JSON in {input}: {e}")), + Err(_) => rynk_kle::decode_layout(text), + } +} + +/// `rmkit layout show`: print the physical layout of a keyboard.toml — or, +/// directly, of a vial.json / raw KLE export — as box-drawing art. Keys are +/// labeled with their matrix position `row,col`; encoder knobs are drawn with +/// rounded corners and labeled `E` plus `↺ ↻`. A character grid cannot draw +/// rotation, so rotated keys render axis-aligned with the angle printed inside. +pub fn show(input: &str, variant: Option<&str>) -> Result<(), String> { + let text = std::fs::read_to_string(input).map_err(|e| format!("cannot read {input}: {e}"))?; + let info = decode_input(input, &text)?; + + let variants: Vec<(usize, &Variant)> = match variant { + Some(name) => { + let idx = info + .variants + .iter() + .position(|v| v.name == name) + .ok_or_else(|| { + let names: Vec<&str> = info.variants.iter().map(|v| v.name.as_str()).collect(); + format!("no variant '{name}' (available: {})", names.join(", ")) + })?; + vec![(idx, &info.variants[idx])] + } + None => info.variants.iter().enumerate().collect(), + }; + + for (idx, v) in variants { + // The default marker only means something when there's a choice. + let is_default = info.variants.len() > 1 && idx == info.default_variant as usize; + print!("{}", render::render_variant(v, is_default)); + println!(); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rynk_kle::decode_layout; + + #[test] + fn two_keys_snapshot() { + let info = decode_layout("rows = 1\ncols = 2\nmap = \"(0,0) (0,1,@2u)\"").unwrap(); + let art = render::render_variant(&info.variants[0], false); + let expected = "\ +variant 'default': 2 keys, 3u × 1u + +┌─────┬───────────┐ +│ 0,0 │ 0,1 │ +│ │ │ +└─────┴───────────┘ +"; + assert_eq!(art, expected); + } + + #[test] + fn numpad_snapshot() { + // The nrf52840_ble example numpad: 2u-tall Plus/Enter (@2u_tall) + // spanning two rows and a 2u-wide zero — pins the junction merging. + let toml = r#" +rows = 5 +cols = 4 +map = """ +(0,0) (0,1) (0,2) (0,3) +(1,0) (1,1) (1,2) (1,3,@2u_tall) +(2,0) (2,1) (2,2) +(3,0) (3,1) (3,2) (3,3,@2u_tall) +(4,0,@2u) (4,1) +""" +"#; + let art = render::render_variant(&decode_layout(toml).unwrap().variants[0], false); + let expected = "\ +variant 'default': 17 keys, 4u × 5u + +┌─────┬─────┬─────┬─────┐ +│ 0,0 │ 0,1 │ 0,2 │ 0,3 │ +│ │ │ │ │ +├─────┼─────┼─────┼─────┤ +│ 1,0 │ 1,1 │ 1,2 │ │ +│ │ │ │ │ +├─────┼─────┼─────┤ 1,3 │ +│ 2,0 │ 2,1 │ 2,2 │ │ +│ │ │ │ │ +├─────┼─────┼─────┼─────┤ +│ 3,0 │ 3,1 │ 3,2 │ │ +│ │ │ │ │ +├─────┴─────┼─────┤ 3,3 │ +│ 4,0 │ 4,1 │ │ +│ │ │ │ +└───────────┴─────┴─────┘ +"; + assert_eq!(art, expected); + } + + #[test] + fn encoder_and_rotation_render() { + // The [0.5] gap keeps the encoder freestanding — adjacent boxes would + // merge its left corners into ┬/┴ junctions instead of ╭/╰. + let info = decode_layout( + "rows = 1\ncols = 1\nmap = \"(0,0,@t) [0.5] (e,0)\"\n[shapes]\nt = { r = 15.0 }", + ) + .unwrap(); + let art = render::render_variant(&info.variants[0], false); + assert!(art.contains("15°"), "rotation angle shown:\n{art}"); + assert!(art.contains("E0"), "encoder label shown:\n{art}"); + assert!(art.contains("↺ ↻"), "rotary arrows shown:\n{art}"); + assert!( + art.contains('╭') && art.contains('╯'), + "encoder corners rounded:\n{art}" + ); + } + + #[test] + fn iso_enter_two_rects_render() { + // @iso_enter pokes above its row (negative y) — exercises the origin offset. + let info = decode_layout("rows = 1\ncols = 2\nmap = \"(0,0) (0,1,@iso_enter)\"").unwrap(); + let v = &info.variants[0]; + assert!(v.keys[1].rect2.is_some()); + let art = render::render_variant(v, false); + assert!(art.contains("0,0") && art.contains("0,1"), "{art}"); + } + + #[test] + fn show_accepts_vial_and_raw_kle_json() { + // A vial.json-shaped input: `row,col` legends, matrix dims, a 2u cap. + let vial = r#"{"matrix": {"rows": 1, "cols": 2}, + "layouts": {"keymap": [["0,0", {"w": 2.0}, "0,1"]]}}"#; + let info = decode_input("vial.json", vial).unwrap(); + let keys = &info.variants[0].keys; + assert_eq!(keys.len(), 2); + assert!((keys[1].rect.w - 2.0).abs() < 1e-3); + + // A raw KLE "Download JSON" export: metadata object first, label + // legends only — positions are assigned row-major. + let kle = r#"[{"name": "plain"}, ["Esc", "Q"], [{"w": 1.5}, "Tab"]]"#; + let info = decode_input("kle_export.json", kle).unwrap(); + let v = &info.variants[0]; + assert_eq!(v.keys.len(), 3); + assert_eq!((v.keys[2].row, v.keys[2].col), (1, 0)); + assert!((v.keys[2].rect.w - 1.5).abs() < 1e-3); + } + + #[test] + fn show_input_errors_match_the_format() { + // Broken content named .json reports the JSON error, not a TOML one. + let e = decode_input("x.json", "{not json").unwrap_err(); + assert!(e.contains("invalid JSON"), "{e}"); + // Anything else falls back to the TOML path. + let e = decode_input("x.toml", "= bad").unwrap_err(); + assert!(e.contains("invalid TOML"), "{e}"); + // Valid JSON that isn't a layout still gets the shape hint. + let e = decode_input("x.json", "{\"a\": 1}").unwrap_err(); + assert!(e.contains("layouts.keymap"), "{e}"); + } +} diff --git a/src/main.rs b/src/main.rs index fba80a6..56e108d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,8 @@ use zip::ZipArchive; mod args; mod chip; mod keyboard_toml; +mod layout_cmd; +mod render; mod version; #[tokio::main] @@ -46,6 +48,24 @@ async fn main() -> Result<(), Box> { println!("{}", project_info.project_name); Ok(()) } + args::Commands::Layout { command } => { + let result = match command { + args::LayoutCommands::Convert { + input, + output, + to_vial, + no_validate, + } => layout_cmd::convert(&input, output.as_deref(), to_vial, !no_validate), + args::LayoutCommands::Show { input, variant } => layout_cmd::show(&input, variant.as_deref()), + }; + // The layout tools speak plain stderr + exit code (their output is + // piped/captured), not the interactive error style of create/init. + if let Err(e) = result { + eprintln!("error: {e}"); + std::process::exit(1); + } + Ok(()) + } } } diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..ab7a316 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,307 @@ +//! Render a decoded layout variant as Unicode box-drawing text. +//! +//! Key-units map onto a fixed character grid (1u = 6 columns × 3 rows, roughly +//! square in a terminal). Every box edge is drawn into a per-cell direction +//! bitmask, so borders shared between keys merge into proper `┬`/`├`/`┼` +//! junctions. Labels overlay the interior afterwards. A character grid cannot +//! draw rotation: rotated keys render axis-aligned at their position with the +//! angle printed inside. + +use rynk_kle::layout::Variant; + +/// Character cells per key-unit. +const SX: f32 = 6.0; +const SY: f32 = 3.0; + +/// Per-cell line-direction bits; `GLYPHS` is indexed by their combination. +const N: u8 = 1; +const S: u8 = 2; +const E: u8 = 4; +const W: u8 = 8; + +const GLYPHS: [char; 16] = [ + ' ', '╵', '╷', '│', '╶', '└', '┌', '├', '╴', '┘', '┐', '┤', '─', '┴', '┬', '┼', +]; + +/// A rectangle in character-cell coordinates, borders inclusive. +#[derive(Clone, Copy)] +struct CellBox { + r0: i32, + c0: i32, + r1: i32, + c1: i32, +} + +impl CellBox { + /// From a center + size in key-units; `(ox, oy)` is the canvas origin. + fn new(x: f32, y: f32, w: f32, h: f32, ox: f32, oy: f32) -> Self { + let c0 = ((x - w / 2.0 - ox) * SX).round() as i32; + let r0 = ((y - h / 2.0 - oy) * SY).round() as i32; + // Clamp to at least one cell so a degenerate shape still draws a box. + let c1 = (((x + w / 2.0 - ox) * SX).round() as i32).max(c0 + 1); + let r1 = (((y + h / 2.0 - oy) * SY).round() as i32).max(r0 + 1); + CellBox { r0, c0, r1, c1 } + } + + fn strictly_contains(&self, r: i32, c: i32) -> bool { + r > self.r0 && r < self.r1 && c > self.c0 && c < self.c1 + } +} + +/// One drawable item: a key (optionally two rectangles) or an encoder. +struct Item { + b: CellBox, + b2: Option, + round: bool, + label: Vec, +} + +struct Canvas { + w: usize, + h: usize, + line: Vec, + round: Vec, + text: Vec, +} + +impl Canvas { + fn set(&mut self, r: i32, c: i32, bits: u8, round: bool) { + if (0..self.h as i32).contains(&r) && (0..self.w as i32).contains(&c) { + let i = r as usize * self.w + c as usize; + self.line[i] |= bits; + self.round[i] |= round; + } + } + + /// Erase line bits strictly inside `b`: keys that physically overlap (e.g. + /// rotated thumbs drawn axis-aligned) occlude what's under them, painter's + /// style, while shared borders still merge into junctions. + fn clear_inside(&mut self, b: CellBox) { + for r in b.r0 + 1..b.r1 { + for c in b.c0 + 1..b.c1 { + if (0..self.h as i32).contains(&r) && (0..self.w as i32).contains(&c) { + let i = r as usize * self.w + c as usize; + self.line[i] = 0; + self.round[i] = false; + } + } + } + } + + /// Draw a box outline. `skip` erases cells interior to the sibling rect of + /// a two-rect key, so the pair renders as its union silhouette. + fn draw_box(&mut self, b: CellBox, round: bool, skip: impl Fn(i32, i32) -> bool) { + for c in b.c0..=b.c1 { + let mut bits = 0; + if c > b.c0 { + bits |= W; + } + if c < b.c1 { + bits |= E; + } + let end = c == b.c0 || c == b.c1; + if !skip(b.r0, c) { + self.set(b.r0, c, bits | if end { S } else { 0 }, round); + } + if !skip(b.r1, c) { + self.set(b.r1, c, bits | if end { N } else { 0 }, round); + } + } + for r in b.r0 + 1..b.r1 { + if !skip(r, b.c0) { + self.set(r, b.c0, N | S, round); + } + if !skip(r, b.c1) { + self.set(r, b.c1, N | S, round); + } + } + } + + /// Center `lines` in the interior of `b`, clipped to fit. + fn label(&mut self, b: CellBox, lines: &[String]) { + let iw = (b.c1 - b.c0 - 1).max(0) as usize; + let ih = (b.r1 - b.r0 - 1).max(0) as usize; + let n = lines.len().min(ih); + let r_start = b.r0 + 1 + ((ih - n) / 2) as i32; + for (i, line) in lines[..n].iter().enumerate() { + let chars: Vec = line.chars().collect(); + let len = chars.len().min(iw); + let c_start = b.c0 + 1 + ((iw - len) / 2) as i32; + for (j, &ch) in chars[..len].iter().enumerate() { + let (r, c) = (r_start + i as i32, c_start + j as i32); + if (0..self.h as i32).contains(&r) && (0..self.w as i32).contains(&c) { + self.text[r as usize * self.w + c as usize] = ch; + } + } + } + } +} + +/// Render one variant: a one-line stats header, a blank line, then the art. +pub fn render_variant(v: &Variant, is_default: bool) -> String { + let mut header = format!("variant '{}'", v.name); + if is_default { + header.push_str(" (default)"); + } + if v.keys.is_empty() && v.encoders.is_empty() { + return format!("{header}: empty (all keys hidden)\n"); + } + + // Bounds over every rectangle, in key-units. + let mut min = (f32::MAX, f32::MAX); + let mut max = (f32::MIN, f32::MIN); + let mut grow = |x: f32, y: f32, w: f32, h: f32| { + min.0 = min.0.min(x - w / 2.0); + min.1 = min.1.min(y - h / 2.0); + max.0 = max.0.max(x + w / 2.0); + max.1 = max.1.max(y + h / 2.0); + }; + for k in &v.keys { + grow(k.rect.x, k.rect.y, k.rect.w, k.rect.h); + if let Some(r2) = &k.rect2 { + grow(r2.x, r2.y, r2.w, r2.h); + } + } + for e in &v.encoders { + // Encoders are fixed 1u knobs: center only. + grow(e.x, e.y, 1.0, 1.0); + } + + let n = v.keys.len(); + header.push_str(&format!(": {n} key{}", if n == 1 { "" } else { "s" })); + let n = v.encoders.len(); + if n > 0 { + header.push_str(&format!(", {n} encoder{}", if n == 1 { "" } else { "s" })); + } + // Snap the size to 0.01u so f32 walk noise doesn't print as 4.0500002. + header.push_str(&format!( + ", {}u × {}u", + ((max.0 - min.0) * 100.0).round() / 100.0, + ((max.1 - min.1) * 100.0).round() / 100.0 + )); + + let items: Vec = v + .keys + .iter() + .map(|k| { + let mut label = vec![format!("{},{}", k.row, k.col)]; + if k.r != 0.0 { + label.push(format!("{}°", k.r)); + } + Item { + b: CellBox::new(k.rect.x, k.rect.y, k.rect.w, k.rect.h, min.0, min.1), + b2: k + .rect2 + .as_ref() + .map(|r2| CellBox::new(r2.x, r2.y, r2.w, r2.h, min.0, min.1)), + round: false, + label, + } + }) + .chain(v.encoders.iter().map(|e| { + // A fixed 1u knob, never resized or angled; the arrows mark it rotary. + let label = vec![format!("E{}", e.id), "↺ ↻".to_string()]; + Item { + b: CellBox::new(e.x, e.y, 1.0, 1.0, min.0, min.1), + b2: None, + round: true, + label, + } + })) + .collect(); + + let (mut w, mut h) = (0i32, 0i32); + for b in items.iter().flat_map(|i| [Some(i.b), i.b2]).flatten() { + w = w.max(b.c1 + 1); + h = h.max(b.r1 + 1); + } + let (w, h) = (w as usize, h as usize); + let mut canvas = Canvas { + w, + h, + line: vec![0; w * h], + round: vec![false; w * h], + text: vec!['\0'; w * h], + }; + + for it in &items { + canvas.clear_inside(it.b); + match it.b2 { + None => canvas.draw_box(it.b, it.round, |_, _| false), + Some(b2) => { + canvas.clear_inside(b2); + canvas.draw_box(it.b, it.round, |r, c| b2.strictly_contains(r, c)); + canvas.draw_box(b2, it.round, |r, c| it.b.strictly_contains(r, c)); + } + } + } + // Occlusion chops edges mid-run, leaving direction bits that point at + // nothing. Prune to a fixpoint: a bit survives only if the neighbor it + // points at holds the reciprocal bit, so remnants end in clean junctions. + loop { + let snap = canvas.line.clone(); + let at = |r: i32, c: i32| -> u8 { + if (0..h as i32).contains(&r) && (0..w as i32).contains(&c) { + snap[r as usize * w + c as usize] + } else { + 0 + } + }; + let mut changed = false; + for r in 0..h as i32 { + for c in 0..w as i32 { + let i = r as usize * w + c as usize; + let mut bits = snap[i]; + if bits & N != 0 && at(r - 1, c) & S == 0 { + bits &= !N; + } + if bits & S != 0 && at(r + 1, c) & N == 0 { + bits &= !S; + } + if bits & E != 0 && at(r, c + 1) & W == 0 { + bits &= !E; + } + if bits & W != 0 && at(r, c - 1) & E == 0 { + bits &= !W; + } + if bits != snap[i] { + canvas.line[i] = bits; + changed = true; + } + } + } + if !changed { + break; + } + } + + // Labels go on top of all lines. + for it in &items { + canvas.label(it.b, &it.label); + } + + let mut out = header; + out.push_str("\n\n"); + for r in 0..h { + let mut row = String::with_capacity(w); + for c in 0..w { + let i = r * w + c; + let ch = if canvas.text[i] != '\0' { + canvas.text[i] + } else { + let bits = canvas.line[i]; + match bits { + b if canvas.round[i] && b == N | E => '╰', + b if canvas.round[i] && b == S | E => '╭', + b if canvas.round[i] && b == N | W => '╯', + b if canvas.round[i] && b == S | W => '╮', + b => GLYPHS[b as usize], + } + }; + row.push(ch); + } + out.push_str(row.trim_end()); + out.push('\n'); + } + out +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..fc3843c --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,183 @@ +//! End-to-end: run the built binary (`rmkit layout convert`) on fixture inputs +//! (vial.json and raw KLE exports) and check the output (including that the +//! generated `[layout]` passes rmk-config validation, which the command reports +//! on stderr and exits non-zero on failure). + +use std::process::Command; + +fn run(fixture: &str) -> std::process::Output { + let path = format!("{}/tests/fixtures/{fixture}", env!("CARGO_MANIFEST_DIR")); + Command::new(env!("CARGO_BIN_EXE_rmkit")) + .args(["layout", "convert", &path]) + .output() + .expect("failed to run rmkit") +} + +#[test] +fn ansi60_split_backspace_converts_and_validates() { + let out = run("ansi60_splitbs.json"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert!(out.status.success(), "converter failed:\n{stderr}"); + // Validation ran and passed (exit 0 already implies it, but be explicit). + assert!(stderr.contains("validation: OK"), "stderr:\n{stderr}"); + + // Matrix size and the stock-width caps come through without shape defs. + assert!(stdout.contains("rows = 5")); + assert!(stdout.contains("cols = 15")); + assert!(stdout.contains("(0,13,@2u)")); // default 2u backspace + assert!(stdout.contains("(1,0,@1.5u)")); // 1.5u tab + assert!(stdout.contains("(2,0,@1.75u)")); // 1.75u caps + assert!(stdout.contains("(4,3,@6.25u)")); // 6.25u space + // Caps are all stock widths; the only generated shape is the 1u reset the + // split-backspace variant uses to shrink (0,13). + assert!(stdout.contains("s1 = { w = 1.0 }")); + + // The split-backspace option became a variant that hides the extra key by + // default and shows/reshapes it in the alternate. + assert!(stdout.contains("[[layout.variant]]")); + assert!(stdout.contains("name = \"default\"")); + assert!(stdout.contains("name = \"Split_Backspace\"")); + assert!(stdout.contains("hidden = [\"(0,14)\"]")); + + // KLE carries no keycodes — the output is geometry only, no [keymap] section + // (the header comment still points the user at authoring one). + assert!(!stdout.contains("\n[keymap]")); + assert!(!stdout.contains("[[keymap.layer]]")); +} + +#[test] +fn raw_kle_export_auto_assigns_matrix() { + // A KLE "Download JSON" file: bare array, metadata object first, label + // legends only — the matrix is derived row-major with a warning. + let out = run("kle_export.json"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert!(out.status.success(), "converter failed:\n{stderr}"); + assert!(stderr.contains("validation: OK"), "stderr:\n{stderr}"); + assert!( + stderr.contains("row-major"), + "expected the auto-assign warning, got:\n{stderr}" + ); + + assert!(stdout.contains("rows = 3")); + assert!(stdout.contains("cols = 4")); + assert!(stdout.contains("(0,0) (0,1) (0,2) (0,3)")); + assert!(stdout.contains("(1,0,@1.5u)")); // 1.5u Tab + assert!(stdout.contains("(2,1,@2u)")); // 2u space +} + +#[test] +fn bare_kle_array_with_matrix_legends_converts() { + // The same KLE array a vial.json would embed, passed directly: `row,col` + // legends win over the row-major fallback, and the undeclared matrix is + // derived silently. + let out = run("kle_rowcol.json"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + assert!(out.status.success(), "converter failed:\n{stderr}"); + assert!(!stderr.contains("warning:"), "stderr:\n{stderr}"); + assert!(stdout.contains("rows = 2")); + assert!(stdout.contains("cols = 3")); + assert!(stdout.contains("(0,0) (0,1) (0,2)")); + assert!(stdout.contains("(1,0,@2u) (1,2)")); // legends place (1,2) after the 2u key +} + +#[test] +fn missing_keymap_is_a_clean_error() { + // A JSON file that isn't a Vial definition should fail with a helpful message. + let out = Command::new(env!("CARGO_BIN_EXE_rmkit")) + .args(["layout", "convert"]) + .arg(format!("{}/Cargo.toml", env!("CARGO_MANIFEST_DIR"))) + .output() + .expect("failed to run"); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("error:"), "stderr:\n{stderr}"); +} + +#[test] +fn converted_toml_fixtures_are_up_to_date() { + // Every JSON fixture has a committed `.toml` golden next to it: the exact + // `layout convert` output. After changing the converter, regenerate with + // for f in tests/fixtures/*.json; do + // cargo run -- layout convert "$f" -o "${f%.json}.toml"; done + // (from the crate root, so the generated-from header stays relative). + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let mut checked = 0; + for entry in std::fs::read_dir(&dir).unwrap().flatten() { + let json = entry.path(); + if !json.extension().is_some_and(|e| e == "json") { + continue; + } + let golden = json.with_extension("toml"); + let expected = std::fs::read_to_string(&golden) + .unwrap_or_else(|_| panic!("missing golden {golden:?} — regenerate (see comment above)")); + let name = json.file_name().unwrap().to_string_lossy(); + let out = Command::new(env!("CARGO_BIN_EXE_rmkit")) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args(["layout", "convert"]) + .arg(format!("tests/fixtures/{name}")) + .output() + .expect("failed to run rmkit"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(stdout, expected, "stale golden for {name} — regenerate (see comment above)"); + checked += 1; + } + assert!(checked >= 5, "expected the committed fixture pairs, found {checked}"); +} + +#[test] +fn layout_show_accepts_vial_and_kle_json() { + let show = |fixture: &str, extra: &[&str]| { + let path = format!("{}/tests/fixtures/{fixture}", env!("CARGO_MANIFEST_DIR")); + Command::new(env!("CARGO_BIN_EXE_rmkit")) + .args(["layout", "show", &path]) + .args(extra) + .output() + .expect("failed to run rmkit") + }; + + // A vial.json renders directly, without converting to keyboard.toml first. + let out = show("corne.json", &[]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(stdout.contains("42 keys"), "stdout:\n{stdout}"); + assert!(stdout.contains("│ 0,0 │"), "stdout:\n{stdout}"); + + // VIA layout options become variants, so --variant works on a vial.json. + let out = show("ansi60_splitbs.json", &["--variant", "Split_Backspace"]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(stdout.contains("variant 'Split_Backspace'"), "stdout:\n{stdout}"); + + // A raw KLE export renders too, with the row-major fallback warning. + let out = show("kle_export.json", &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{stderr}"); + assert!(stderr.contains("row-major"), "stderr:\n{stderr}"); +} + +#[test] +fn layout_show_renders_box_art() { + // `layout show` on a bare map snippet prints the box-drawing art. + let dir = std::env::temp_dir().join("rmkit_cli_show_test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("layout.toml"); + std::fs::write(&path, "rows = 1\ncols = 2\nmap = \"(0,0) (0,1)\"\n").unwrap(); + + let out = Command::new(env!("CARGO_BIN_EXE_rmkit")) + .args(["layout", "show"]) + .arg(&path) + .output() + .expect("failed to run"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "show failed:\n{stderr}"); + assert!(stdout.contains("2 keys"), "stdout:\n{stdout}"); + assert!(stdout.contains("┌─────┬─────┐"), "stdout:\n{stdout}"); +} diff --git a/tests/fixtures/BAlice65_vial.json b/tests/fixtures/BAlice65_vial.json new file mode 100644 index 0000000..43257ce --- /dev/null +++ b/tests/fixtures/BAlice65_vial.json @@ -0,0 +1,85 @@ +{ + "name": "BAlice65 BT", + "vendorId": "0xFEED", + "productId": "0x1120", + "lighting": "none", + "matrix": { + "rows": 5, + "cols": 17 + }, + "layouts": { + "keymap": [ + [{"x":0.5},"0,0",{"x":16.11,"w":2},"0,15",{"x":0.64},"0,16","0,0\n\n\n\n\n\n\n\n\ne","0,1\n\n\n\n\n\n\n\n\ne"], + [{"x":18.42,"w":1.5},"1,15"], + [{"y":-0.75,"x":20.75},"3,15"], + [{"y":-0.75},"1,0"], + [{"y":-0.5,"x":18,"w":2.25},"2,14"], + [{"y":-0.75,"x":20.75},"3,16"], + [{"y":-0.75},"2,0"], + [{"y":-0.5,"x":17.75,"w":1.75},"2,15"], + [{"y":-0.75,"x":19.75},"4,14"], + [{"y":-0.75},"2,1"], + [{"y":-0.5,"x":1.55,"w":1.25},"4,0"], + [{"y":-0.75,"x":18.75},"4,13","4,15","4,16"], + [{"r":0.85,"rx":2.1,"ry":28.5,"y":-26.5,"x":-0.6,"w":1.75},"2,2"], + [{"r":1,"ry":7.5,"y":-4.5,"x":-0.6,"w":2.25},"3,2"], + [{"r":1.45,"ry":28.5,"y":-27.5,"x":-0.6,"w":1.5},"1,1"], + [{"r":1.9,"y":-2,"x":-0.6},"0,1"], + [{"r":4,"y":-1,"x":-0.6},"0,2"], + [{"r":4.7,"x":-0.6},"1,2"], + [{"r":4.8,"x":-0.6},"2,3"], + [{"r":5.25,"y":1,"x":-0.6,"w":1.25},"4,1"], + [{"r":5.4,"y":-2,"x":-0.6},"3,3"], + [{"r":6.1,"y":-4,"x":-0.6},"0,3"], + [{"r":6.9,"x":-0.6},"1,3"], + [{"r":7.1,"x":-0.6},"2,4"], + [{"r":7.8,"x":-0.6},"3,4"], + [{"r":8.2,"y":-4,"x":-0.6},"0,4"], + [{"r":8.35,"y":3,"x":-0.6,"w":1.25},"4,4"], + [{"r":9.1,"y":-4,"x":-0.6},"1,4"], + [{"r":9.4,"x":-0.6},"2,5"], + [{"r":10.2,"x":-0.6},"3,5"], + [{"r":10.3,"y":-4,"x":-0.6},"0,5"], + [{"r":11.3,"x":-0.6},"1,5"], + [{"r":11.7,"x":-0.6},"2,6"], + [{"r":12.4,"y":-3,"x":-0.6},"0,6"], + [{"r":12.5,"ry":26.5,"y":-22.52,"x":-0.6,"w":2.25},"4,5"], + [{"r":12.6,"ry":28.5,"y":-25.5,"x":-0.6},"3,6"], + [{"r":13.5,"y":-3,"x":-0.6},"1,6"], + [{"r":14,"x":-0.6},"2,7"], + [{"r":14.5,"y":-3,"x":-0.6},"0,7"], + [{"r":15,"y":2,"x":-0.6},"3,7"], + [{"r":15.7,"y":-3,"x":-0.6},"1,7"], + [{"r":-15.5,"rx":18.6,"y":-28.5,"x":-0.6},"0,8"], + [{"r":-15,"y":2,"x":-0.6},"3,8"], + [{"r":-14.5,"y":-3,"x":-0.6},"1,8"], + [{"r":-13.8,"x":-0.6},"2,8"], + [{"r":-13.4,"y":-3,"x":-0.6},"0,9"], + [{"r":-12.6,"y":2,"x":-0.6},"3,9"], + [{"r":-12.5,"ry":28,"y":-23.98,"x":-1.45,"w":2.75},"4,8"], + [{"r":-12.3,"ry":28.5,"y":-27.5,"x":-0.6},"1,9"], + [{"r":-11.5,"x":-0.6},"2,9"], + [{"r":-11.3,"y":-3,"x":-0.6},"0,10"], + [{"r":-10.2,"y":2,"x":-0.6},"3,10"], + [{"r":-10.1,"y":-3,"x":-0.6},"1,10"], + [{"r":-9.2,"y":-2,"x":-0.6},"0,11"], + [{"y":1,"x":-0.6},"2,10"], + [{"r":-7.9,"y":-2,"x":-0.6},"1,11"], + [{"r":-7.8,"y":1,"x":-0.6},"3,11"], + [{"r":-7.55,"x":-0.6,"w":1.25},"4,10"], + [{"r":-7.1,"y":-5,"x":-0.6},"0,12"], + [{"r":-6.9,"y":1,"x":-0.6},"2,11"], + [{"r":-5.7,"y":-2,"x":-0.6},"1,12"], + [{"r":-5.4,"y":1,"x":-0.6},"3,12"], + [{"r":-5,"y":-4,"x":-0.6},"0,13"], + [{"r":-4.6,"y":1,"x":-0.6},"2,12"], + [{"r":-4.45,"y":1,"x":-0.6},"4,11"], + [{"r":-3.5,"y":-4,"x":-0.6},"1,13"], + [{"r":-3,"y":1,"x":-0.6},"3,13"], + [{"r":-2.9,"y":-4,"x":-0.6},"0,14"], + [{"r":-2.3,"y":1,"x":-0.6},"2,13"], + [{"r":-1.95,"y":1,"x":-0.6},"4,12"], + [{"r":-1.3,"y":-4,"x":-0.6},"1,14"] + ] + } +} diff --git a/tests/fixtures/BAlice65_vial.toml b/tests/fixtures/BAlice65_vial.toml new file mode 100644 index 0000000..56b8393 --- /dev/null +++ b/tests/fixtures/BAlice65_vial.toml @@ -0,0 +1,135 @@ +# Generated from tests/fixtures/BAlice65_vial.json by rmkit — review the geometry. The source +# carries no keycodes, so author the [keymap] yourself: its `keys` follow the +# map's key order, plus one ["cw", "ccw"] pair per encoder in `encoders`. + +[layout] +rows = 5 +cols = 17 +map = """ +[0.5] (0,0) [16.11] (0,15,@2u) [0.64] (0,16) [0.5] (e,0) +[18.42] (1,15,@1.5u) +[y=-0.75] +[20.75] (3,15) +[y=-0.75] +(1,0) +[y=-0.5] +[18.0] (2,14,@2.25u) +[y=-0.75] +[20.75] (3,16) +[y=-0.75] +(2,0) +[y=-0.5] +[17.75] (2,15,@1.75u) +[y=-0.75] +[19.75] (4,14) +[y=-0.75] +(2,1) +[y=-0.5] +[1.55] (4,0,@1.25u) +[y=-0.75] +[18.75] (4,13) (4,15) (4,16) +[y=-3.25] +[r=0.85@(2.1,28.5)] [1.5] (2,2,@1.75u) +[r=1.0@(2.1,7.5)] [1.5] (3,2,@2.25u) +[y=-3.0] +[r=1.45@(2.1,28.5)] [1.5] (1,1,@1.5u) +[y=-2.0] +[r=1.9@(2.1,28.5)] [1.5] (0,1) +[y=-1.0] +[r=4.0@(2.1,28.5)] [1.5] (0,2) +[r=4.7@(2.1,28.5)] [1.5] (1,2) +[r=4.8@(2.1,28.5)] [1.5] (2,3) +[y=1.0] +[r=5.25@(2.1,28.5)] [1.5] (4,1,@1.25u) +[y=-2.0] +[r=5.4@(2.1,28.5)] [1.5] (3,3) +[y=-4.0] +[r=6.1@(2.1,28.5)] [1.5] (0,3) +[r=6.9@(2.1,28.5)] [1.5] (1,3) +[r=7.1@(2.1,28.5)] [1.5] (2,4) +[r=7.8@(2.1,28.5)] [1.5] (3,4) +[y=-4.0] +[r=8.2@(2.1,28.5)] [1.5] (0,4) +[y=3.0] +[r=8.35@(2.1,28.5)] [1.5] (4,4,@1.25u) +[y=-4.0] +[r=9.1@(2.1,28.5)] [1.5] (1,4) +[r=9.4@(2.1,28.5)] [1.5] (2,5) +[r=10.2@(2.1,28.5)] [1.5] (3,5) +[y=-4.0] +[r=10.3@(2.1,28.5)] [1.5] (0,5) +[r=11.3@(2.1,28.5)] [1.5] (1,5) +[r=11.7@(2.1,28.5)] [1.5] (2,6) +[y=-3.0] +[r=12.4@(2.1,28.5)] [1.5] (0,6) +[y=2.98] +[r=12.5@(2.1,26.5)] [1.5] (4,5,@2.25u) +[y=-1.98] +[r=12.6@(2.1,28.5)] [1.5] (3,6) +[y=-3.0] +[r=13.5@(2.1,28.5)] [1.5] (1,6) +[r=14.0@(2.1,28.5)] [1.5] (2,7) +[y=-3.0] +[r=14.5@(2.1,28.5)] [1.5] (0,7) +[y=2.0] +[r=15.0@(2.1,28.5)] [1.5] (3,7) +[y=-3.0] +[r=15.7@(2.1,28.5)] [1.5] (1,7) +[y=-2.0] +[r=-15.5@(18.6,28.5)] [18.0] (0,8) +[y=2.0] +[r=-15.0@(18.6,28.5)] [18.0] (3,8) +[y=-3.0] +[r=-14.5@(18.6,28.5)] [18.0] (1,8) +[r=-13.8@(18.6,28.5)] [18.0] (2,8) +[y=-3.0] +[r=-13.4@(18.6,28.5)] [18.0] (0,9) +[y=2.0] +[r=-12.6@(18.6,28.5)] [18.0] (3,9) +[y=0.02] +[r=-12.5@(18.6,28.0)] [17.15] (4,8,@2.75u) +[y=-4.02] +[r=-12.3@(18.6,28.5)] [18.0] (1,9) +[r=-11.5@(18.6,28.5)] [18.0] (2,9) +[y=-3.0] +[r=-11.3@(18.6,28.5)] [18.0] (0,10) +[y=2.0] +[r=-10.2@(18.6,28.5)] [18.0] (3,10) +[y=-3.0] +[r=-10.1@(18.6,28.5)] [18.0] (1,10) +[y=-2.0] +[r=-9.2@(18.6,28.5)] [18.0] (0,11) +[y=1.0] +[18.0] (2,10) +[y=-2.0] +[r=-7.9@(18.6,28.5)] [18.0] (1,11) +[y=1.0] +[r=-7.8@(18.6,28.5)] [18.0] (3,11) +[r=-7.55@(18.6,28.5)] [18.0] (4,10,@1.25u) +[y=-5.0] +[r=-7.1@(18.6,28.5)] [18.0] (0,12) +[y=1.0] +[r=-6.9@(18.6,28.5)] [18.0] (2,11) +[y=-2.0] +[r=-5.7@(18.6,28.5)] [18.0] (1,12) +[y=1.0] +[r=-5.4@(18.6,28.5)] [18.0] (3,12) +[y=-4.0] +[r=-5.0@(18.6,28.5)] [18.0] (0,13) +[y=1.0] +[r=-4.6@(18.6,28.5)] [18.0] (2,12) +[y=1.0] +[r=-4.45@(18.6,28.5)] [18.0] (4,11) +[y=-4.0] +[r=-3.5@(18.6,28.5)] [18.0] (1,13) +[y=1.0] +[r=-3.0@(18.6,28.5)] [18.0] (3,13) +[y=-4.0] +[r=-2.9@(18.6,28.5)] [18.0] (0,14) +[y=1.0] +[r=-2.3@(18.6,28.5)] [18.0] (2,13) +[y=1.0] +[r=-1.95@(18.6,28.5)] [18.0] (4,12) +[y=-4.0] +[r=-1.3@(18.6,28.5)] [18.0] (1,14) +""" diff --git a/tests/fixtures/ansi60_splitbs.json b/tests/fixtures/ansi60_splitbs.json new file mode 100644 index 0000000..88e150b --- /dev/null +++ b/tests/fixtures/ansi60_splitbs.json @@ -0,0 +1,35 @@ +{ + "name": "ANSI 60 (test fixture)", + "vendorId": "0x4C4B", + "productId": "0x0060", + "matrix": { "rows": 5, "cols": 15 }, + "layouts": { + "labels": ["Split Backspace"], + "keymap": [ + [ + "0,0", "0,1", "0,2", "0,3", "0,4", "0,5", "0,6", "0,7", "0,8", + "0,9", "0,10", "0,11", "0,12", + {"w": 2}, "0,13", + {"x": -2}, "0,13\n\n\n\n\n\n\n\n\n0,1", "0,14\n\n\n\n\n\n\n\n\n0,1" + ], + [ + {"w": 1.5}, "1,0", "1,1", "1,2", "1,3", "1,4", "1,5", "1,6", + "1,7", "1,8", "1,9", "1,10", "1,11", "1,12", {"w": 1.5}, "1,13" + ], + [ + {"w": 1.75}, "2,0", "2,1", "2,2", "2,3", "2,4", "2,5", "2,6", + "2,7", "2,8", "2,9", "2,10", "2,11", {"w": 2.25}, "2,12" + ], + [ + {"w": 2.25}, "3,0", "3,1", "3,2", "3,3", "3,4", "3,5", "3,6", + "3,7", "3,8", "3,9", "3,10", {"w": 2.75}, "3,11" + ], + [ + {"w": 1.25}, "4,0", {"w": 1.25}, "4,1", {"w": 1.25}, "4,2", + {"w": 6.25}, "4,3", + {"w": 1.25}, "4,9", {"w": 1.25}, "4,10", {"w": 1.25}, "4,11", + {"w": 1.25}, "4,12" + ] + ] + } +} diff --git a/tests/fixtures/ansi60_splitbs.toml b/tests/fixtures/ansi60_splitbs.toml new file mode 100644 index 0000000..98330ab --- /dev/null +++ b/tests/fixtures/ansi60_splitbs.toml @@ -0,0 +1,26 @@ +# Generated from tests/fixtures/ansi60_splitbs.json by rmkit — review the geometry. The source +# carries no keycodes, so author the [keymap] yourself: its `keys` follow the +# map's key order, plus one ["cw", "ccw"] pair per encoder in `encoders`. + +[layout] +rows = 5 +cols = 15 +default_variant = "default" +map = """ +(0,0) (0,1) (0,2) (0,3) (0,4) (0,5) (0,6) (0,7) (0,8) (0,9) (0,10) (0,11) (0,12) (0,13,@2u) (0,14) +(1,0,@1.5u) (1,1) (1,2) (1,3) (1,4) (1,5) (1,6) (1,7) (1,8) (1,9) (1,10) (1,11) (1,12) (1,13,@1.5u) +(2,0,@1.75u) (2,1) (2,2) (2,3) (2,4) (2,5) (2,6) (2,7) (2,8) (2,9) (2,10) (2,11) (2,12,@2.25u) +(3,0,@2.25u) (3,1) (3,2) (3,3) (3,4) (3,5) (3,6) (3,7) (3,8) (3,9) (3,10) (3,11,@2.75u) +(4,0,@1.25u) (4,1,@1.25u) (4,2,@1.25u) (4,3,@6.25u) (4,9,@1.25u) (4,10,@1.25u) (4,11,@1.25u) (4,12,@1.25u) +""" + +[layout.shapes] +s1 = { w = 1.0 } + +[[layout.variant]] +name = "default" +hidden = ["(0,14)"] + +[[layout.variant]] +name = "Split_Backspace" +shapes = { "(0,13)" = "@s1" } diff --git a/tests/fixtures/corne.json b/tests/fixtures/corne.json new file mode 100644 index 0000000..963d88b --- /dev/null +++ b/tests/fixtures/corne.json @@ -0,0 +1,28 @@ +{ + "name": "Crkbd", + "vendorId": "0x4653", + "productId": "0x0001", + "lighting": "vialrgb", + "matrix": { "rows": 8, "cols": 6 }, + "layouts": { + "keymap": [ + [{"y":1,"x":3.5},"0,3",{"x":7.5},"4,3"], + [{"y":-0.875,"x":2.5},"0,2",{"x":1},"0,4",{"x":5.5},"4,4",{"x":1},"4,2"], + [{"y":-0.875,"x":5.5},"0,5",{"x":3.5},"4,5"], + [{"y":-0.875,"x":0.5},"0,0","0,1",{"x":11.5},"4,1","4,0"], + [{"y":-0.375,"x":3.5},"1,3",{"x":7.5},"5,3"], + [{"y":-0.875,"x":2.5},"1,2",{"x":1},"1,4",{"x":5.5},"5,4",{"x":1},"5,2"], + [{"y":-0.875,"x":5.5},"1,5",{"x":3.5},"5,5"], + [{"y":-0.875,"x":0.5},"1,0","1,1",{"x":11.5},"5,1","5,0"], + [{"y":-0.375,"x":3.5},"2,3",{"x":7.5},"6,3"], + [{"y":-0.875,"x":2.5},"2,2",{"x":1},"2,4",{"x":5.5},"6,4",{"x":1},"6,2"], + [{"y":-0.875,"x":5.5},"2,5",{"x":3.5},"6,5"], + [{"y":-0.875,"x":0.5},"2,0","2,1",{"x":11.5},"6,1","6,0"], + [{"y":-0.125,"x":4},"3,3",{"x":6.5},"7,3"], + [{"r":15,"rx":4.5,"ry":9.1,"y":-4.85,"x":-0.5},"3,4"], + [{"r":30,"rx":5.4,"ry":9.3,"y":-5.05,"x":-1.4,"h":1.5},"3,5"], + [{"r":-30,"rx":11.1,"y":-5.05,"x":0.4,"h":1.5},"7,5"], + [{"r":-15,"rx":12,"ry":9.1,"y":-4.85,"x":-0.5},"7,4"] + ] + } +} diff --git a/tests/fixtures/corne.toml b/tests/fixtures/corne.toml new file mode 100644 index 0000000..872fd38 --- /dev/null +++ b/tests/fixtures/corne.toml @@ -0,0 +1,45 @@ +# Generated from tests/fixtures/corne.json by rmkit — review the geometry. The source +# carries no keycodes, so author the [keymap] yourself: its `keys` follow the +# map's key order, plus one ["cw", "ccw"] pair per encoder in `encoders`. + +[layout] +rows = 8 +cols = 6 +map = """ +[3.5] (0,3) [7.5] (4,3) +[y=-0.875] +[2.5] (0,2) [1.0] (0,4) [5.5] (4,4) [1.0] (4,2) +[y=-0.875] +[5.5] (0,5) [3.5] (4,5) +[y=-0.875] +[0.5] (0,0) (0,1) [11.5] (4,1) (4,0) +[y=-0.375] +[3.5] (1,3) [7.5] (5,3) +[y=-0.875] +[2.5] (1,2) [1.0] (1,4) [5.5] (5,4) [1.0] (5,2) +[y=-0.875] +[5.5] (1,5) [3.5] (5,5) +[y=-0.875] +[0.5] (1,0) (1,1) [11.5] (5,1) (5,0) +[y=-0.375] +[3.5] (2,3) [7.5] (6,3) +[y=-0.875] +[2.5] (2,2) [1.0] (2,4) [5.5] (6,4) [1.0] (6,2) +[y=-0.875] +[5.5] (2,5) [3.5] (6,5) +[y=-0.875] +[0.5] (2,0) (2,1) [11.5] (6,1) (6,0) +[y=-0.125] +[4.0] (3,3) [6.5] (7,3) +[y=-1.0] +[r=15.0@(4.5,8.1)] [4.0] (3,4) +[y=-1.0] +[r=30.0@(5.4,8.3)] [4.0] (3,5,@s1) +[y=-1.0] +[r=-30.0@(11.1,8.3)] [11.5] (7,5,@s1) +[y=-1.0] +[r=-15.0@(12.0,8.1)] [11.5] (7,4) +""" + +[layout.shapes] +s1 = { h = 1.5 } diff --git a/tests/fixtures/kle_export.json b/tests/fixtures/kle_export.json new file mode 100644 index 0000000..d34f744 --- /dev/null +++ b/tests/fixtures/kle_export.json @@ -0,0 +1,6 @@ +[ + { "name": "Sample KLE export", "author": "rmk" }, + ["Esc", "Q", "W", "E"], + [{ "w": 1.5 }, "Tab", "A", "S"], + [{ "y": 0.5, "x": 1 }, "Z", { "w": 2 }, "Space"] +] diff --git a/tests/fixtures/kle_export.toml b/tests/fixtures/kle_export.toml new file mode 100644 index 0000000..1c0c921 --- /dev/null +++ b/tests/fixtures/kle_export.toml @@ -0,0 +1,13 @@ +# Generated from tests/fixtures/kle_export.json by rmkit — review the geometry. The source +# carries no keycodes, so author the [keymap] yourself: its `keys` follow the +# map's key order, plus one ["cw", "ccw"] pair per encoder in `encoders`. + +[layout] +rows = 3 +cols = 4 +map = """ +(0,0) (0,1) (0,2) (0,3) +(1,0,@1.5u) (1,1) (1,2) +[y=0.5] +[1.0] (2,0) (2,1,@2u) +""" diff --git a/tests/fixtures/kle_rowcol.json b/tests/fixtures/kle_rowcol.json new file mode 100644 index 0000000..ddcdeb0 --- /dev/null +++ b/tests/fixtures/kle_rowcol.json @@ -0,0 +1,4 @@ +[ + ["0,0", "0,1", "0,2"], + [{ "w": 2 }, "1,0", "1,2"] +] diff --git a/tests/fixtures/kle_rowcol.toml b/tests/fixtures/kle_rowcol.toml new file mode 100644 index 0000000..b50b72d --- /dev/null +++ b/tests/fixtures/kle_rowcol.toml @@ -0,0 +1,11 @@ +# Generated from tests/fixtures/kle_rowcol.json by rmkit — review the geometry. The source +# carries no keycodes, so author the [keymap] yourself: its `keys` follow the +# map's key order, plus one ["cw", "ccw"] pair per encoder in `encoders`. + +[layout] +rows = 2 +cols = 3 +map = """ +(0,0) (0,1) (0,2) +(1,0,@2u) (1,2) +"""