diff --git a/.gitignore b/.gitignore index 800a4e2c..a61bbeeb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ compile_commands.json .clangd/ .cache/ + +# Python caches +__pycache__/ +*.pyc diff --git a/users/raphaelmor/HDPM-layers.html b/users/raphaelmor/HDPM-layers.html new file mode 100644 index 00000000..978759d4 --- /dev/null +++ b/users/raphaelmor/HDPM-layers.html @@ -0,0 +1,208 @@ + + + + + +Hands Down Promethium — full layer map + + + +

Hands Down · Promethium — full layer map

+

Canonical moutis HD system (github.com/moutis/HandsDown), rendered for a 3×6+3 Corne. +Seven layers total; the six functional ones are shown (QWERTY is just a plain-QWERTY compatibility fallback). +Small top-right badge = hold behavior (mod or layer). Layers are reached by holding a thumb/home key on the alpha layer.

+ +
+ alpha / letter + mod (hold) + layer-tap / switch + semantic key (SK_*) + symbol + number / nav + media + RGB / config + ▽ transparent +
+ +
+ +

How to reach each layer from the alpha layer (all momentary, via layer-tap holds): +SYM = hold Space · NAV = hold R (L-thumb) or Enter (R-thumb) · FUN = hold Bksp (L-thumb) or F · NUM = hold K · CFG = hold Mute/Play (outer thumbs), or the CFG combo (both inner-thumb pairs). +Home-row mods on the alpha & FUN/NUM layers: pinky=⌃ ring=⌥ middle=⌘ index=⇧ (both hands).

+ + + + diff --git a/users/raphaelmor/HDPM-parity.md b/users/raphaelmor/HDPM-parity.md new file mode 100644 index 00000000..ae16042a --- /dev/null +++ b/users/raphaelmor/HDPM-parity.md @@ -0,0 +1,242 @@ +# Hands Down Promethium — feature parity checklist + +Exhaustive list of everything the **canonical** moutis Hands Down repo supports for the +**Promethium (pm)** variant, for porting into this firmware. Sourced from +`../HandsDown/handsdown/pm-adaptive.c`, `../HandsDown/handsdown/pm-config.h`, +`../HandsDown/moutis_combo_def.c`, `../HandsDown/moutis.h`. Updated against **main @ `a46d847`** (SemKeys-refactor / QWERTY-optional era). + +Legend: `[x]` already in this firmware · `[ ]` not yet · **(hold)** = distinct action when held past `COMBO_HOLD`/`LINGER_TIME`. + +> **Status (implemented).** Adaptives: **complete PM set incl. the 3-key rules** via `preprior` tracking, +> gated to the Hands Down base (`ramo_adaptive.c`). **ADAPT_SHIFT** (comma-leader one-shot Shift), HDP-only. +> Combos: `qu`/`z`/`th` + Shift-Tab + App-menu + diacritics/ligatures + `; : ! ?`, all at canonical positions, +> HDP-gated (`ramo_combos.c`). Linger brackets `( {` + smart caps-word + Shift+Bksp→Del (`ramo_linger.c`, +> `ramo_process_record.c`). Option-A thumbs; HD outer columns blanked. Mac-only → Semantic-Keys engine +> intentionally **not** ported. + +> **Porting note.** Our HDP base now uses moutis's **canonical arrangement** (V W G M J top-left, +> `# . / " '` top-right, F P D L X bottom-left, `- U O Y B` bottom-right), so combos map to his exact +> positions. Kept customizations: GACS home-row mods, Hyper/Meh on fixed top positions (G/M, ./,/), +> blanked outer pinky columns, Option-A thumbs (Colemak-aligned). Home rows match canonical (`S N T H K` / `, A E I C`). + +Canonical PM reference layout (positions → letters): +``` + V W G M J #$ .: /* "[ '] + S N T H K () ,; A E I C Z(RM5) + F P D L X [ ] -+ U O Y B Qu(RB5) + bsp R spc ret +``` + +--- + +## 1. Adaptive keys (`pm-adaptive.c`) + +Fire only if the previous key was within `ADAPTIVE_TERM` (rolling window). All eliminate an +SFB/scissor by rewriting the roll. "→ del X" = backspaces the already-typed key first. + +### Left hand +- [x] **P D → PWD** — on `D` after `P`: insert `W` (then D). *(simplified from the old 3-key P·B·D in the refactor)* +- [x] **P F → PS** — on `F` after `P`: type `S` instead of F +- [x] **K G → KL** — on `G` after `K`: type `L` instead of G +- [x] **W M G → WML** — on `G` after `W,M`: type `L` instead of G *(3-key, via preprior)* +- [x] **M G → LG** — on `G` after `M`: del M, type `L`, then G +- [x] **J G → JPG** — on `G` after `J`: insert `P`, then G +- [x] **W G → WD** — on `G` after `W`: type `D` instead of G +- [x] **K H → KN** — on `H` after `K`: type `N` instead of H +- [x] **G J → G+"th"** — on `J` after `G`: type `th` (for "length") +- [x] **V J / W J → …L** — on `J` after `W`: type `L` instead of J. *(`V J` **removed** — V+J is the `˜` tilde combo)* +- [x] **M K → LK** — on `K` after `M`: del M, type `L`, then K +- [x] **H K → NK** — on `K` after `H`: del H, type `N`, then K +- [ ] **P/B/S then L → unshifted L** — skipped by choice (low value on this HDP arrangement) +- [x] **G M → GL** — on `M` after `G`: type `L` instead of M +- [x] **V M → VL** — on `M` after `V`: type `L` instead of M +- [ ] **M W M → …L** — **removed**: unreachable, W+M is the `qu` combo (see below) +- [ ] **W M → "lm"** — **removed**: unreachable, W+M is the `qu` combo. Combos resolve in + `pre_process_record_quantum`, *before* the adaptive engine runs — a roll fast enough for the + adaptive is also inside `COMBO_TERM`, so `qu` always won. Kept the combo; `Q` needs a home. +- [x] **F P → SP** — on `P` after `F`: del F, type `S`, then P +- [x] **G V → GT** — on `V` after `G`: type `T` instead of V +- [x] **M V → LV** — on `V` after `M`: del M, type `L`, then V +- [x] **G W → GD** — on `W` after `G`: type `D` instead of W +- [x] **M W → MP** — on `W` after `M`: type `P` instead of W + +### Right hand +- [x] **Y B → IB** — on `B` after `Y`: del Y, type `I`, then B +- [x] **A E → AU** — on `E` after `A`: type `U` instead of E + +### Adaptive infrastructure +- [x] **Base-gating** — adaptives run only when Hands Down is the active default layer (`ramo_adaptive.c`); Colemak types clean. +- [x] **preprior tracking** — engine keeps `prior` AND `preprior` (enables the 3-key rules above) +- [x] **ADAPT_SHIFT** (`,` leader) — HDP-only: `,` then a letter within `RAMO_ADAPT_SHIFT_TERM` → capitalizes it, deletes the comma (`ramo_process_record.c`) +- [ ] **HD_MAGIC key** (`#`) + **HD_MAGIC_B** (`Bksp`) — "magic"/repeat-style text expansion (`.com`, `.org`, `.edu`, etc. via `adapt_magic.c`). Not pursued. Note: canonical `,,` toggles **Caps Lock**. + +--- + +## 2. Combos (`moutis_combo_def.c` → `key_combos[]`) + +### 2a. Off-map alphas — *needed to type these letters ergonomically* +- [x] **qu** (`W+M`, canonical) **(hold: delete u → q)** +- [x] **z** (`N+H`) + +### 2b. H-digraph combos — `th` is **enabled** (`USE_HD_H_DIGRAPH_COMBO`, `HDP-defs.h`); the rest are optional +- [x] **th** (`T+H`) — live on HDP; Colemak gets its own (`CMK_Th_keys`, `S+T` positions) +- [ ] **ch** (`I+C`, or `N+T`) +- [ ] **sh** (`S+N`) +- [ ] **wh** (`W+G`) +- [ ] **ph** (`Y+B`, or `P+L`) +- [ ] **gh** (`G+M`) +- [ ] **sch** (`S+N+T`) + +### 2c. Whitespace / editing keys +- [x] **Tab** — on thumb (not a combo) +- [x] **Shift-Tab** (`V+W+G`) — combo +- [x] **App menu** (`M+J`) — combo +- [ ] **Space / Enter** — on thumbs already (no combo needed) +- [ ] **hard-Enter / page break** (`X+L+D`, `SK_HENT`) — needs SemKeys (skipped, Mac-only) +- [ ] **Config layer MO(L_CFG)** — no CFG layer in our scheme +- [ ] **Num-layer toggle** (`R + Space`) → `TG(L_NUM)` — optional, not added + +### 2d. Semantic editing combos (all via SemKeys — see §3) +- [ ] **New** (`X+K`) +- [ ] **Open** (`(-)+(,)`) +- [ ] **Close** (`(#)+(,)`) +- [ ] **Quit** (`J+K`) +- [ ] **Find** (`S+K`) **(hold: find-selection)** +- [ ] **Select All** (`F+L`) +- [ ] **Select Word** (`F+X`) +- [ ] **Undo** (`F+P`) +- [ ] **Redo** (`F+P+D`) +- [ ] **Copy** (`P+D`) **(hold: Cut)** +- [ ] **Paste** (`D+L`) **(hold: Paste-and-Match)** + +### 2e. Symbol / punctuation combos (canonical top-right cluster) — HDP base only +- [x] **;** (`,+A`) +- [x] **:** (`.+"`) +- [x] **!** (`.+/`) +- [x] **?** (`/+"`) +- [ ] `$ § @ _ ~ = –` — not added (belong on **SYM v2**, where there's room) + +### 2f. Ligatures — HDP base only, Mac ⌥ dead-keys +- [x] **Œ** (`E+O` → ⌥Q) +- [x] **Æ** (`A+U` → ⌥') + +### 2g. Dead-key diacritics (Mac ⌥) — HDP base only, canonical positions +- [x] **´ acute** (`A+E` → ⌥E) +- [x] **` grave** (`E+I` → ⌥`) +- [x] **ˆ circumflex** (`A+I` → ⌥I) +- [x] **¨ dieresis** (`U+O` → ⌥U) +- [x] **˜ tilde / ñ** (`V+J` → ⌥N) +- [x] **¸ cedilla** (`S+T` → ⌥C) +- [ ] **¯ macron** (`E+C`) / **˚ ring** (`A+C`) — not added (rare) + +### 2h. Screen / system (via SemKeys) +- [ ] **Esc** (`S+H`) +- [ ] **Force-quit** (`(")+(#)`, `SK_KILL`) +- [ ] **Screenshot → file** (`W+G+M+J`) +- [ ] **Screenshot → clipboard** (`G+M+J`) +- [ ] **Platform search / Siri** (`T+H+K`) +- [ ] **Caps Word** (`Y+B`) — *combo only;* the feature itself is done, but **implemented differently** (see §3). + ⚠️ `Y+B` is already the **YB → IB adaptive** (`ramo_adaptive.c`). Porting this combo means moving one of them. +- [ ] **Caps Lock** (`O+Y+B`) +- [ ] **kana / eisuu toggles** (`I+E+A` / `N+T+H`) + +### 2i. Custom text-macro combos +- [ ] **LeftCombo** (`W+G+M`) → user string **(hold: alt string)** +- [ ] **RightCombo** (`(.)+(/)+(")`) → user string **(hold: alt string)** + +### 2j. Numpad combos (on L_NUM; position-based, ~22) +- [ ] Enter, Bksp, Del, Tab, Shift-Tab on numpad +- [ ] `– ~ … ( ) : % °` and currency `€ ¥ $ ¢ £ ÷` , `± ≠` +- [ ] Clear (`C`), All-Clear (`Esc`), Num-Lock toggle + +### 2k. Pronoun combos — *optional (`EN_PRONOUN_COMBOS`), moutis calls them "unnecessary"* +- [ ] `I` (`I+C`), `I'd` (`I+D`), `I'll` (`I+L`), `I'm` (`I+M`), `I've` (`I+V`) — most **(hold: +'ve)** +- [ ] (`_ALL`) you'd / you'll / you're / you've / your, they / their / they're / they'll / they'd +- [ ] (`EN_W_PRONOUNS`) we'd / we'll / we're / we've / where + +### 2l. Japanese youon combos — *optional (`JP_MODE_ENABLE`), ~50 kana* +- [ ] きゃ〜りょ contracted-sound combos (skip unless you type Japanese) + +--- + +## 3. Feature systems (`moutis.h` + engine files) + +- [x] **Home-row mods** (all base-layer alphas) +- [x] **Thumb layer-taps** — Colemak: Esc→MEDIA, Spc→NAV, Tab→MOUSE, Ent→SYM, Bspc→NUM, Del→FUN. HDP (Option A): thumbs aligned to Colemak, only R→SYM (inner-right) & Ent→FUN (outer-right) differ; Del→**Shift+Bksp**. +- [x] **Caps Word** — **different implementation, same intent.** We use QMK's built-in `caps_word` + the + `caps_word_press_user` hook (`ramo_linger.c`); moutis rolls his own engine (`moutis_casemods.c`, + ~144 lines, from Andrew Rae's pre-QMK-builtin implementation). Behavioral differences worth knowing: + | | Ours (QMK builtin) | Moutis (`moutis_casemods.c`) | + |---|---|---| + | Shifting | weak mods per letter | toggles real **Caps Lock** (`tap_code(KC_CAPS)`) — for iOS etc. | + | Activation | `CW_TOGG` on NAV | `,,` adaptive + `Y+B` combo | + | space → `_` | in `process_record` | in the engine, tracks `last_press_was_space` | + | double-space | *(nothing special)* | exits and **rewrites** `_` back to a real space | + | Auto-off | `CAPS_WORD_IDLE_TIMEOUT` (5 s default) | `STATE_RESET_TIME * 3` ≈ 1.8 s, in `matrix_scan` | + His Caps-Lock approach survives host-side remapping that weak-mod shifting doesn't; ours needs no + `matrix_scan` work and no LED-state round-trip. Only port his if the weak-mod version misbehaves somewhere. +- [ ] **Semantic keys (`SK_*`)** — cross-platform abstraction. **Intentionally skipped — you're Mac-only**, so `⌘C/⌘V/⌘Z` on NAV stay as-is. — `moutis_semantickeys.c` +- [x] **Linger keys** — hold `(` `{` → auto-paired with caret inside (`ramo_linger.c`, `RAMO_LINGER_TERM`). `"` is unmapped and `[` isn't on **any** layer yet (SYM has `{ } ( )` only) — both need a SYM v2 home. +- [ ] **Key overrides** — manual Shift/Alt variants of punctuation. (Only Shift+Bksp→Del is done, for the Option-A thumb change.) +- [ ] **OS platform switching** — N/A (Mac-only). +- [ ] **Adaptive on/off toggle** — not needed: adaptives are auto-gated to the HD base, off on Colemak. +- [ ] **Japanese input mode** — `JP_MODE_ENABLE` (not pursued). +- [~] **Layer structure** — unchanged: our 8-layer scheme (COLEMAK/HDP/MEDIA/NAV/MOUSE/SYM/NUM/FUN). Not adopting canonical HD's separate layers. + +--- + +## 4. Timings (`config.h`) — ours vs. canonical + +Deliberately **not** matched to moutis. His values are in `../HandsDown/config.h` + `moutis.h`. +WPM equivalent = `12000 / ms` (5 chars/word), i.e. the speed *above* which a window still catches you. + +| Constant | Ours | Moutis | Note | +|---|---|---|---| +| `TAPPING_TERM` | 200 | 170 | | +| **Adaptive window** | **175 ms** (~69 WPM) | 229 ms (`COMBO_HOLD*1.35`, ~52 WPM) | see below | +| ADAPT_SHIFT window | 200 ms | 918 ms (`ADAPTIVE_TERM*4`) | | +| `COMBO_TERM` | 50 (QMK default) | 42 (`TT/4`) | | +| Combo hold / linger | 200 / 200 | 170 / 204 | | +| `TAP_CODE_DELAY` | **20** | 20 | adopted — at 0, back-to-back taps in del-then-retype adaptives can drop or reorder | +| `QUICK_TAP_TERM` | **0** | 170 (default) | intentional: disables double-tap-hold auto-repeat | +| `PERMISSIVE_HOLD` | off | on | intentional (for now) | + +**Adaptive window — tuned empirically (`tt verify`), 50 → 100 → 175 ms.** +The key insight: **adaptives fix SFBs and scissors, which are slow motions by definition** — so a +window tuned to *roll* speed systematically fails the highest-value rules. At 100 ms, fast +adjacent-finger rolls passed (`PD`, `WG`) while `KH` (same-finger, index→index) and `KG` +(inner-column stretch) never fired at all. 175 ms clears those motions while staying well under +moutis's always-armed 229 ms. + +**Window is measured with `record->event.time`, not `timer_read()`.** For a mod-tap/layer-tap, +`process_record_user` only sees the record once the tap resolves (on release, or after +`TAPPING_TERM`), so read-time would charge the hold duration against the window. 12 keys are +mod-taps (`G M . /` + both home rows), i.e. most adaptive triggers. + +**3-key rules need two consecutive intervals inside the window**, so their failure rate is roughly +the square of the 2-key rate — expect them to be the last to become reliable. + +**Known collisions to watch at 175 ms** — same-row rolls that appear in real words: +`GM` in *pragma* / *progmem* · `PD` in *update* · `YB` in *keyboard* · `MW` in *firmware* · `KG` in *background*. +None observed yet in practice. `MK` (*qmk*) is safe, being same-finger. + +**Combo-vs-adaptive collisions:** combos resolve in `pre_process_record_quantum`, before the adaptive +engine. Any rule whose two keys are also a combo is unreachable — `W M`/`M W M` (`qu`) and `V J` (`˜`) +were removed for this. Check new adaptives against `ramo_combos.c` before adding. + + +--- + +## Done vs. remaining +**Done:** complete PM adaptives incl. 3-key rules (preprior) · ADAPT_SHIFT · `qu`/`z`/`th` · Shift-Tab · App-menu · diacritics + ligatures (`é è ê ü ñ ç œ æ`) · `; : ! ?` symbol combos · linger `( {` · smart caps-word · Shift+Bksp→Del · Option-A thumbs · canonical arrangement · blanked HD outer columns. **All HDP-gated where base-specific. Builds ~64 KB.** + +**Still open (by choice / next):** + +> **Current focus: learning the layout, not extending it.** The firmware is feature-complete for +> daily driving. Everything below is deferred until real use shows it's needed — including the +> adaptive-window collisions in §4. + +1. **SYM "Programming v2"** — bracket/operator layer for Swift/Odin/ObjC (mock in `hd-colemak.html`). Would also give `[` (currently on no layer) and `"` a home for linger. +2. **Colemak-DH** — optional base swap (6 keys), mocked as a toggle. +3. Nice-to-have combos: `ch/sh/wh/ph/gh` digraphs (`th` already on); `$ § @ ~ = –` (better on SYM v2). +4. **Not pursued (Mac-only / design):** Semantic keys → so §2d editing & §2h screen combos (they need SemKeys), HD_MAGIC, macron/ring diacritics, pronouns (§2k), Japanese (§2l), numpad combos (§2j). diff --git a/users/raphaelmor/LICENSE b/users/raphaelmor/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/users/raphaelmor/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/users/raphaelmor/QMK-feature-audit.md b/users/raphaelmor/QMK-feature-audit.md new file mode 100644 index 00000000..8757dcd6 --- /dev/null +++ b/users/raphaelmor/QMK-feature-audit.md @@ -0,0 +1,328 @@ +# QMK feature audit — candidates for this firmware + +Survey of **everything QMK offers**, judged against *this* keymap (Colemak + Hands Down +Promethium on a Corne v4.1). Companion to [`HDPM-parity.md`](HDPM-parity.md): that doc tracks +parity with **moutis' Hands Down**, this one tracks parity with **QMK core**. + +Sources: QMK `../qmk` @ `3ef9860ee1` (`0.29.8-768`, 2026-07-22) · userspace `users/raphaelmor/`. + +Legend: `[x]` in this firmware · `[ ]` not here · **T1/T2** = recommended tier · **✗** = examined +and rejected, with the reason. + +> **The framing.** `HDPM-parity.md:235-237` freezes feature work ("learning the layout, not +> extending it"). So the bar below is not *"is this interesting"* but **"does it reduce friction +> while learning, or fix something already broken?"** +> +> **The one-line finding:** this firmware is unusually complete on the **typing-transformation** +> axis (adaptives, combos, linger, comma-shift, smart Caps Word) and near-empty on the +> **tap-hold-correctness** and **feedback** axes. 16 home-row mod-taps run with *zero* misfire +> protection. That asymmetry is where all the value is. + +--- + +## 1. What's already on + +**`rules.mk`:** Caps Word · Combos · Tap Dance · Console + `KEYCODE_STRING` · introspection. + +**Inherited from `crkbd/rev4_1`:** RGB Matrix · split · Mouse Keys · OLED · encoders · +bootmagic · NKRO · extrakey. + +**Hand-rolled, with no QMK feature behind it:** + +| Behaviour | Where | QMK equivalent | +|---|---|---| +| Adaptive keys (24 rules) | `ramo_adaptive.c` | none — genuinely custom | +| Linger brackets `( {` | `ramo_linger.c:10-38` | none | +| SCREAMING_SNAKE Caps Word | `ramo_linger.c:80-97` | none (extends builtin Caps Word) | +| Comma-leader one-shot Shift | `ramo_process_record.c:48-60` | loosely, one-shot mods | +| Shift+Bspc → Del | `ramo_process_record.c:35-42` | **Key Overrides** → §3.3 | +| Combo hold-to-linger | `ramo_combos.c:154-167` | none | + +--- + +## 2. The full QMK catalog + +### 2a. Tap/hold and timing — `docs/tap_hold.md` +- [x] `TAPPING_TERM` (200) · `QUICK_TAP_TERM` (0) +- [ ] **`CHORDAL_HOLD`** — same-hand nested key forces the tap. → **T1, §3.1** +- [ ] **`FLOW_TAP_TERM`** — suppress hold shortly after another key. → **T2, §4.1** +- [ ] `PERMISSIVE_HOLD` / `HOLD_ON_OTHER_KEY_PRESS` — → **T1, pairs with §3.1** +- [ ] **`DYNAMIC_TAPPING_TERM_ENABLE`** (`DT_UP`/`DT_DOWN`/`DT_PRNT`) → **T2, §4.3** +- [ ] `RETRO_TAPPING` / Retro Shift — ✗ fights home-row mods +- [ ] `SPECULATIVE_HOLD` (`docs/tap_hold.md:819`) — ✗ very new, perturbs the exact path the adaptives ride +- [ ] `TAPPING_TERM_PER_KEY` · selectable debounce — not needed yet +- **Achordion is not in QMK core.** It's Getreuer's userspace library; `CHORDAL_HOLD` is the + in-tree replacement. Don't go looking for it. + +### 2b. Text / typing aids +- [x] Caps Word (`docs/features/caps_word.md`) — see §4.5 for two free upgrades +- [x] Combos · Tap Dance · Send String +- [ ] **Repeat / Alt-Repeat Key** (`QK_REP`/`QK_AREP`) → **T1, §3.2** +- [ ] **Key Overrides** → **T1, §3.3** +- [ ] **Layer Lock** (`QK_LLCK`) → **T2, §4.2** +- [ ] Autocorrect — ✗ §5 +- [ ] Leader Key — ✗ §5 +- [ ] Unicode / Unicode Map / UCIS / `send_unicode_string()` — ✗ §5 (Mac ⌥ dead keys by design) +- [ ] Auto Shift · Space Cadet · one-shot/sticky mods · Dynamic Macros · Key Lock (`QK_LOCK`) · + Swap Hands · Secure · Grave Escape · Magic keycodes · Stenography — ✗ §5 + +### 2c. Input devices +- [x] Mouse Keys (tuned, `config.h:17-26`) +- [~] **Encoders** — compiled in, *inert*, and pin-blocked → **§4.7** +- [ ] Pointing Device · PS/2 Mouse · Digitizer · Joystick · Programmable Buttons · DIP Switch — no hardware / no need + +### 2d. Output / feedback +- [x] RGB Matrix — static ledmap only, all 44 stock animations `#undef`'d (`config.h:56-101`) +- [~] **OLED** — enabled at keyboard level, **no `oled_task_user`** → **T1, §3.4** +- [ ] **WPM** (+ `SPLIT_WPM_ENABLE`) → **T2, §4.4** +- [ ] LED Matrix · Backlight · LED Indicators · ST7565/HD44780 · Quantum Painter · Audio/Music/Clicky · + MIDI · Sequencer · Haptic — not applicable or not wanted + +### 2e. Infrastructure +- [x] Split + `SPLIT_LAYER_STATE_ENABLE` · introspection · Community Modules + (`elpekenin/ledmap` + `elpekenin/generics`, 2 of ~25) +- [ ] Deferred execution — ✗ §5 (polling was a deliberate choice) +- [ ] Remaining `SPLIT_*_ENABLE` flags: `LED_STATE` `MODS` `WPM` `OLED` `POINTING` `HAPTIC` `ACTIVITY` +- [ ] EEPROM/NVM/wear-leveling · VIA · Raw HID · Tri Layer · OS Detection · Wireless/Battery — not needed + +--- + +## 3. Tier 1 — worth doing + +### 3.1 Chordal Hold — the standout gap +`#define CHORDAL_HOLD` · `docs/tap_hold.md:568` + +**Why.** 16 home-row mod-taps across two base layers at `TAPPING_TERM 200`, with +`PERMISSIVE_HOLD` off, `HOLD_ON_OTHER_KEY_PRESS` off, and **no misfire protection whatsoever** +(`HDPM-parity.md:202` records this as "intentional (for now)"). Chordal Hold settles a mod-tap as +a *tap* whenever the next key is on the **same hand**, and leaves cross-hand chords to +`HOLD_ON_OTHER_KEY_PRESS`/`PERMISSIVE_HOLD`. Hands Down Promethium is built around same-hand +rolls, and the adaptive rules deliberately target same-hand SFBs and scissors (`KH`, `KG`, `MG`) — +exactly the motions that misfire a mod today. + +**⚠️ It is not a one-liner on this board.** Three things verified in the tree: + +**(a) The auto-generated handedness table misses 10 of your 46 keys.** +Handedness is generated as a *weak* `chordal_hold_layout` +(`../qmk/lib/python/qmk/cli/generate/keyboard_c.py:207-239`), but the generator **reads only the +first layout in `info.json`** — for `crkbd/rev4_1` that's `LAYOUT_split_3x5_3` (36 keys), while +this keymap uses `LAYOUT_split_3x6_3_ex2` (46). The 10 uncovered matrix positions stay `0`, which +is neither `'L'`, `'R'`, nor `'*'`: + +| Uncovered | Positions | HDP | Colemak | +|---|---|---|---| +| Outer pinky columns | `(0,0) (1,0) (2,0) (4,0) (5,0) (6,0)` → `L/R T5 M5 B5` | `KC_NO` | `KC_NO` | +| The four `ex2` keys | `(0,6) (1,6) (4,6) (5,6)` → `LTA LMA RTA RMA` | `KC_NO` | **`LCTL_T(KC_A)`** | + +Per `get_chordal_hold_default` (`../qmk/quantum/action_tapping.c:940-951`) a `0` compares unequal +to everything, so those keys always permit the hold — i.e. Chordal Hold is silently *off* for +them. Almost entirely harmless here, since 6 of the 10 are `KC_NO` on both bases; the only live +case is the four `ex2` keys on Colemak, which are all `LCTL_T(KC_A)` (`CMK-defs.h:77,79,93,95`). +If §3.2 puts `QK_REP`/`QK_AREP` on those keys they become non-tap-hold and the issue disappears +entirely. + +**(b) The thumbs get real `'L'`/`'R'` handedness, and that breaks every layer.** +The geometry guess only assigns `'*'` to a spacebar or a dead-centre key; all six thumbs land +firmly L or R. Every layer here puts its *payload* on the hand opposite its activating thumb — +but its **same-side modifier block on the same hand as that thumb**. With a bare +`#define CHORDAL_HOLD`, chording the thumb with its own hand's mods within `TAPPING_TERM` settles +the layer-tap as a tap: + +| Thumb | Same-hand keys on the target layer | Broken result | +|---|---|---| +| `LT(L_MEDIA, KC_ESC)` `HDP-defs.h:77` | `KC_LCTL/LALT/LGUI/LSFT` `MED-defs.h:37-40`, `KC_ALGR :55` | types `Esc` | +| `LT(L_NAV, KC_SPC)` `:78` | left mods `NAV-defs.h:37-40` **+ both base-switch tap dances** `:23-24` | types `Space` | +| `LT(L_MOUSE, KC_TAB)` `:79` | left mods `MOS-defs.h:36-39` | types `Tab` | +| `LT(L_SYM, KC_R)` `:80` | `KC_RSFT/RGUI/RALT/RCTL` `SYM-defs.h:46-49` | types `r` | +| `LT(L_NUM, KC_BSPC)` `:81` | right mirror mods `NUM-defs.h:46-49` | types `Backspace` | +| `LT(L_FUN, KC_ENT)` `:82` | right mirror mods `FUN-defs.h:46-49` | types `Enter` | + +Scope, stated honestly: "Chordal Hold has no effect after the tapping term", so this only bites +when the second key lands within 200 ms of the thumb press — which is precisely normal fast layer +use. **Prerequisite: define `chordal_hold_layout` over `LAYOUT_split_3x6_3_ex2` with `'*'` on all +six thumbs** — the docs recommend exactly this for thumb keys, and it fixes (a) at the same time +by covering all 46 positions. + +**(c) The 17 combos are safe.** `get_chordal_hold_default` returns `true` for non-`KEY_EVENT` +records (`action_tapping.c:941-943`), so combos are exempt from the opposite-hands rule. + +**Pair it with `HOLD_ON_OTHER_KEY_PRESS`** — the docs state Chordal Hold is *intended* to be used +with that or Permissive Hold; alone, the cross-hand case is unchanged. + +**If adopted, verify:** `tt verify` on the same-hand adaptives most at risk — `KH` (same-finger +index), `KG` (inner-column stretch), and the 3-key `WMG` — plus confirm cross-hand `⌘C`/`⌘V` and +`⇧`+letter still hold. Expect adaptives to get *more* reliable, not less: same-hand taps now +settle at the next keydown instead of at release/timeout, which shortens the very delay documented +at `ramo_adaptive.c:12-18`. + +### 3.2 Repeat Key — and it answers the four dead keys +`REPEAT_KEY_ENABLE = yes` · `QK_REP` / `QK_AREP` · `docs/features/repeat_key.md` + +Same problem domain as the adaptive engine — eliminating same-finger bigrams — but with **zero +collision risk**, because it never rewrites already-typed text and so can't fight combos the way +`W M`/`V J` did (`HDPM-parity.md:223-225`). moutis' own HD layouts lean on it. + +**Placement is already decided by the keymap:** all four `ex2` positions carry +`// TODO: should be a real key once a use is found` — `KC_NO` on HDP (`HDP-defs.h:33,35,49,51`) +and a pointless duplicate `LCTL_T(KC_A)` on Colemak (`CMK-defs.h:77,79,93,95`). `QK_REP` and +`QK_AREP` are the answer to that TODO, and doing so also erases finding §3.1(a). +Alt-Repeat's context-dependent inverse (`←`→`→`, PgDn→PgUp) is a real NAV-layer win. + +**Caveat to check, not assume:** `process_last_key` and `process_repeat_key` run at +`../qmk/quantum/quantum.c:343`, **upstream of `process_record_kb`/`process_record_user` (line +355)**. So the adaptive engine's `prior_keycode`/`preprior_keycode` history will see the +*repeated* keycode rather than `QK_REP`. That's probably what you want — a repeat should count as +the letter for adaptive purposes — but it needs a `tt verify` pass to confirm the interaction with +the del-then-retype rules. + +### 3.3 Key Overrides — strictly less code than what's there +`KEY_OVERRIDE_ENABLE = yes` · `docs/features/key_overrides.md` · already tracked `[ ]` at +`HDPM-parity.md:180` + +`ramo_process_record.c:35-42` hand-rolls Shift+Bspc→Del with manual `clear_mods()`/`set_mods()`. +`ko_make_with_layers_negmods_and_options` (`../qmk/quantum/process_keycode/process_key_override.h:135`) +expresses it declaratively, and `negative_mod_mask` (`:69`) is the exact equivalent of the current +`!(get_mods() & ~MOD_MASK_SHIFT)` guard — so ⌘⇧Bspc keeps *not* becoming Del. Also gives a proper +home for future shifted-punctuation variants. + +**Behavioural difference to record:** `process_key_override` runs at `quantum/quantum.c:387`, +*after* `process_record_user`. Today's early `return false` means the adaptive dispatch never sees +the keypress; with an override it would run first and set `prior_keycode = KC_BSPC`. Harmless as +far as the current 24 rules go (none trigger on `KC_BSPC`), but it is a real change in state. + +### 3.4 `oled_task_user` — the display is currently lying +No `oled_task_user` exists anywhere in the userspace, so stock `crkbd.c` renders: it prints +**"Lower" for Hands Down and "Undef" for every layer ≥ 4**, against an 8-layer keymap. + +The DRY fix reuses `RAMO_FOR_EACH_LAYER` (`ramo_layers.h:16-24`) — already the single source for +both `keymaps[]` (`keymap.c:49-57`) and `ledmap[]` (`ramo_ledmap.c:6-13`) — to generate a +layer-name table from the same list, so a layer can't have a keymap without a name. Worth showing +alongside: active base (CMK vs HDP, from `default_layer_state`), Caps Word state, and WPM (§4.4). + +`elpekenin/ui` is already in the `modules/` submodule if you'd rather not hand-roll the drawing. + +--- + +## 4. Tier 2 — good fits, lower stakes + +### 4.1 Flow Tap — `#define FLOW_TAP_TERM 150` · `docs/tap_hold.md:485` +Complement to Chordal Hold: suppresses the hold when a tap-hold key is pressed shortly after +another key. Same mental model as `RAMO_ADAPTIVE_TERM 175` (`config.h:35-49`) — a rolling +typing-speed window. + +**⚠️ It would break the thumbs as shipped.** The default `is_flow_tap_key()` +(`docs/tap_hold.md:504`) matches `KC_A`–`KC_Z`, `KC_SPC`, `KC_COMM`, `KC_DOT`, `KC_SCLN`, `KC_SLSH` +— applied to `get_tap_keycode()`, so it captures `LT(L_NAV, KC_SPC)` (`HDP-defs.h:78`) and HDP's +`LT(L_SYM, KC_R)` (`:80`). A letter followed within `FLOW_TAP_TERM` by a reach for thumb-NAV or +thumb-SYM would emit a literal space or `r`. **Overriding `is_flow_tap_key()` to exclude thumb +layer-taps is mandatory, not optional** (the docs even call out removing `case KC_SPC`). + +**Upside for the adaptives:** Flow Tap forces early tap resolution, which directly mitigates the +late-`event.time` problem documented at `ramo_adaptive.c:12-18`. + +### 4.2 Layer Lock — `LAYER_LOCK_ENABLE = yes` · `QK_LLCK` · `docs/features/layer_lock.md` +Long numeric entry on NUM and extended mouse work on MOS without holding a thumb the whole time. +Both layers have plenty of `*_NO` slots to host it. + +### 4.3 Dynamic Tapping Term — `DYNAMIC_TAPPING_TERM_ENABLE = yes` · `docs/tap_hold.md:79` +`DT_UP`/`DT_DOWN`/`DT_PRNT` tune the tapping term live and type out the current value. This is the +same empirical loop already used for `RAMO_ADAPTIVE_TERM` (50 → 100 → 175, `config.h:35-49`), but +without a reflash per iteration. FUN has free slots. + +**Caveat:** it switches the *runtime* value to `g_tapping_term` while the `TAPPING_TERM` **macro** +stays 200 — so `RAMO_COMBO_HOLD`, `RAMO_LINGER_TERM` and `RAMO_ADAPT_SHIFT_TERM` +(`config.h:32,33,52`) would stay pinned at 200 while the real tapping term moved. Use +`GET_TAPPING_TERM(keycode, record)` if they should track it. + +### 4.4 WPM — `WPM_ENABLE = yes` + `#define SPLIT_WPM_ENABLE` +Direct progress feedback during the learning phase, surfaced on the OLED from §3.4. Cheap, and it +is the one addition that measures whether the layout is actually being learned. + +### 4.5 Caps Word activation extras — two defines, no code +Caps Word is used heavily already (`CW_TOGG` on `NAV_RM0`, plus the `Y+B` combo +`ramo_combos.c:76`). +- `BOTH_SHIFTS_TURNS_ON_CAPS_WORD` — both index home-row shifts (`LSFT_T(KC_H)` + + `RSFT_T(KC_A)`), which is a **cross-hand** chord and so composes cleanly with §3.1. +- `CAPS_WORD_INVERT_ON_SHIFT` — shift inverts instead of breaking the word. +- Also worth knowing: `CAPS_WORD_IDLE_TIMEOUT` defaults to 5 s; moutis uses ~1.8 s + (`HDPM-parity.md:175`). + +### 4.6 `QK_BOOT` / `EE_CLR` — currently on no layer at all +Flashing relies entirely on the RP2040 double-tap-reset. Cheap insurance on FUN, which has free +slots. (Both appear in the `HDPM-layers.html` mock-up but exist on no real layer.) + +### 4.7 Encoders — **check the hardware before spending any effort** +`info.json` declares 2 encoders per side, but there is no `encoder_map`, no `ENCODER_MAP_ENABLE` +and no `encoder_update_user` — so they're compiled in and inert. + +**Verified pin conflict:** encoder pins are GP5/GP6/GP7 (left) and GP24/GP6/GP7 (right) +(`../qmk/keyboards/crkbd/rev4_1/info.json:36-37,65-66`) against `I2C1_SDA_PIN GP6` / +`I2C1_SCL_PIN GP7` (`../qmk/keyboards/crkbd/rev4_1/config.h:10-11`). **Encoders and the OLED +share the same pins on rev4.1 and are mutually exclusive.** Decide which you want before writing +an encoder map or an `oled_task_user`. + +--- + +## 5. Examined and rejected + +- **✗ Autocorrect** — collides with the adaptive engine conceptually: both are armed on every + keystroke and both rewrite via backspace. Worse, during a learning phase it **masks the layout + errors you need to feel**. Revisit once HDP is automatic. +- **✗ Leader Key** — redundant against 17 combos; a second chord vocabulary to memorise for no new + capability. +- **✗ Auto Shift / Retro Shift** — fights home-row mods and the comma-leader shift + (`ramo_process_record.c:48-60`). +- **✗ Deferred executors** — polling via `matrix_scan_user` was chosen *deliberately* to dodge + roll-over ordering bugs; the reasoning is at `ramo_linger.c:5-8`. Leave it. +- **✗ Unicode / Unicode Map / UCIS / `send_unicode_string()`** — the whole diacritic set is Mac ⌥ + dead keys by design (`ramo_combos.c`, `HDPM-parity.md:181`). Only relevant if a non-Mac host + ever appears, and then **OS Detection** (`docs/features/os_detection.md`) is the companion. +- **✗ Grave Escape** — Esc is a thumb layer-tap (`HDP-defs.h:77`), so `QK_GESC` has nowhere to go. +- **✗ Speculative Hold** (`docs/tap_hold.md:819`) — genuine latency win, but very new and it + perturbs the exact tap-hold path the adaptives depend on. Not while also learning the layout. +- **✗ One-shot mods** — the comma-leader shift is better suited to this layout than `OSM(MOD_LSFT)`. +- **✗ Space Cadet · Swap Hands · Dynamic Macros · Secure · Key Lock · Tri Layer · Programmable + Buttons · Digitizer · Joystick · MIDI · Sequencer · Audio · Haptic · Stenography · Quantum + Painter · VIA · Raw HID** — no problem in this firmware that they solve. + +--- + +## 6. Defects noticed during the survey (documented, not fixed) + +1. **`saved_modifiers` is dead code.** Declared `raphaelmor.c:10` and read at `ramo_combos.c:108` + as the "don't fire combos while ⌥/⌘/⌃ is held" guard — but **never assigned anywhere**. As a + zero-initialised global it is permanently 0, so the guard never fires. Intent was presumably + `get_mods()`. +2. **The HD Gold path does not compile.** Un-defining `RAMO_USE_HD_PROMETHIUM` (`config.h:34`) + breaks the build: `HDG-defs.h:186,189` define `HD_Q_keys`/`HD_Z_keys` in terms of `HDP_*` + positions, and `ramo_combos.c:13-34` references `HDP_*` unconditionally, but `ramo_layers.h:2-6` + includes one defs file or the other, never both. +3. **`rules.mk:5`** is `// TEMP: for debugging adaptive`. `//` is not a Make comment, and the line + contains a `:`, so Make parses it as a stray rule target with prerequisites. Harmless in + practice (QMK sets its default goal first) — but it is not a comment. +4. **`HDPM-parity.md:138`** marks Caps Word `Y+B` as not done; it *is* implemented + (`ramo_combos.c:34,76`). + +--- + +## 7. Toolchain blocker + +The `qmk` CLI is currently broken on this machine — `/opt/homebrew/bin/qmk` has a dangling +interpreter (`/opt/homebrew/Cellar/qmk/1.1.8/libexec/bin/python`). The userspace `Makefile` +resolves `QMK_FIRMWARE_ROOT` via `qmk config -ro user.qmk_home`, so **nothing here can be built +until that's repaired** (`brew reinstall qmk`). Unrelated to this audit, but it gates acting on +any of it. + +--- + +## 8. Summary + +| Priority | Feature | Effort | Note | +|---|---|---|---| +| **T1** | Chordal Hold + `HOLD_ON_OTHER_KEY_PRESS` | 2 defines **+ a 46-key handedness table with `'*'` thumbs** | biggest win; do not ship without the table | +| **T1** | Repeat / Alt-Repeat on the four `ex2` keys | 1 flag + 4 keycodes | answers an existing TODO; also erases §3.1(a) | +| **T1** | Key Overrides | 1 flag + 1 table; **deletes** `ramo_process_record.c:35-42` | net less code | +| **T1** | `oled_task_user` | ~30 lines via `RAMO_FOR_EACH_LAYER` | the OLED is currently wrong | +| **T2** | Flow Tap | 1 define + `is_flow_tap_key()` override | override is mandatory | +| **T2** | Layer Lock · Dynamic Tapping Term · WPM · Caps Word defines · `QK_BOOT`/`EE_CLR` | flags + keycodes | cheap | +| **T2** | Encoder map | medium | **blocked on the OLED-vs-encoder pin decision** | diff --git a/users/raphaelmor/README.md b/users/raphaelmor/README.md index d9467bc6..eebbf210 100644 --- a/users/raphaelmor/README.md +++ b/users/raphaelmor/README.md @@ -1,4 +1,15 @@ -# HANDS DOWN GOLD and COLEMAK MIRYOKU CUSTOM SETUP FOR CORNE +# Corne v4.1 · QMK + +The Corne implementation of my [Colemak + Hands Down Promethium +layout](https://github.com/raphaelmor/keyboard-layout). + +**The layout itself is documented there**, not here — the alphas, the six layers, +the combos, the adaptive keys and the always-on behaviors are the same whichever +board runs them. This file covers only what is true of this board and this +firmware. + +Everything below the marker is generated from the C sources by +`tools/kle_docs.py`. Edit around it, not inside it. ## TODO @@ -27,3 +38,105 @@ SPRING : Colemak ORANGE : HD-p or g CHARTREUSE : Core Keys of the layer (arrows on NAV, numbers on NUM etc...) WHITE : Other Keys for the layer (home,end, copy cut on NAV, etc..) + + + + + +## The board + +Corne v4.1, `LAYOUT_split_3x6_3_ex2` (`keymap.c`): 46 switches — 3x6 per +half, three thumbs, plus the four ex2 inner keys `LTA LMA RTA RMA`. + +Only 36 of them carry anything, so the pictures in the layout +repo draw a 3x5 + 3. See *Not drawn* below for the rest. + +## Not drawn + +The board has 46 switches. These 10 are left off: + +- Outer pinky column `LT5 LM5 LB5 RT5 RM5 RB5` — `KC_NO` on every layer. +- Inner ex2 keys `LTA LMA RTA RMA`. + +Careful: these hidden positions do have something bound. + +- `CMK_LTA` is `LCTL_T(KC_A)` +- `CMK_RTA` is `LCTL_T(KC_A)` +- `CMK_LMA` is `LCTL_T(KC_A)` +- `CMK_RMA` is `LCTL_T(KC_A)` + +## Build flags and timings + +All from `config.h`, read at generation time. + +``` + define value effect + ------------------------------------------------------------------------------- + RAMO_USE_HD_PROMETHIUM set Hands Down Promethium + TAPPING_TERM 200 hold to get the mod, the layer, the bracket pair + RAMO_ADAPTIVE_TERM 175 keydown to keydown, for an adaptive to fire +``` + +## Where each feature lives + +All paths are relative to `users/raphaelmor/`. + +``` + feature file + ------------------------------------------------------------------------------------------------- + layer list and order ramo_layers.h + key and colour per position layers/{CMK,HDP,HDG,MED,NAV,MOS,SYM,NUM,FUN}-defs.h + the keymap itself ../../keyboards/crkbd/rev4_1/standard/keymaps/raphaelmor/keymap.c + combos ramo_combos.c + adaptive keys ramo_adaptive.c + linger brackets, Caps Word ramo_linger.c + Shift+Bksp, comma-leader shift ramo_process_record.c + base switch (tap dance) ramo_tap_dance.c + per-key RGB ramo_ledmap.c +``` + +## Regenerating the docs + +``` +python3 tools/kle_docs.py # this file and the layout repo +python3 tools/kle_export.py # corne-v4-1-ramo.json, for KLE +``` + +`kle_export.py` runs the C preprocessor over `ramo_layers.h`, so it reads +the same keymap the firmware builds. It aborts instead of writing a wrong +file: an unknown keycode, a combo whose keys do not resolve, or a legend +too long for a cap all stop the export. + +To open it in [keyboard-layout-editor.com](http://www.keyboard-layout-editor.com), +use the **Upload** button and pick `corne-v4-1-ramo.json`. Do not paste that +file into the **Raw data** tab — that tab wraps whatever you type in `[ ]` of +its own, so a whole `.json` file ends up double-wrapped and draws one empty +key. Paste this instead: + +``` +python3 tools/kle_export.py --raw -o /tmp/kle-raw.txt && pbcopy < /tmp/kle-raw.txt +``` + + + +## License + +**GPL-3.0-or-later.** See [LICENSE](LICENSE). + +This directory is not under the GPL-2.0 licence at the root of `qmk_userspace`, +which covers the userspace template. The design here follows [Hands Down +Promethium by moutis](https://github.com/moutis/HandsDown), whose QMK +implementation is GPL-3.0. QMK itself is GPL-2.0-**or-later**, and that "or +later" is what lets the two combine: the result is GPL-3.0. + +A keyboard layout — which letter sits on which position — is a system rather than +an expressive work, so no licence is owed for the arrangement itself. The +attribution is given because it is deserved, not because it is required. + +Layer structure follows [Miryoku by +manna-harbour](https://github.com/manna-harbour/miryoku). That project publishes +no licence, which is another reason nothing of its is copied here. + +`tools/` is MIT, not GPL. Those scripts are original and share nothing with +upstream, and they generate the [layout +repo](https://github.com/raphaelmor/keyboard-layout), which is MIT. diff --git a/users/raphaelmor/config.h b/users/raphaelmor/config.h index 7cfcaede..42b0c9c5 100644 --- a/users/raphaelmor/config.h +++ b/users/raphaelmor/config.h @@ -1,3 +1,13 @@ +/* + * config.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once // default but used in macros @@ -7,6 +17,12 @@ // Enable rapid switch from tap to hold, disables double tap hold auto-repeat. #define QUICK_TAP_TERM 0 +// Gap between register/unregister in tap_code(). At the default 0, back-to-back +// taps in the adaptives (del-then-retype, e.g. MG -> LG) can land in the same USB +// frame and go missing or out of sequence. 20 is moutis's value. +#undef TAP_CODE_DELAY +#define TAP_CODE_DELAY 20 + // Mouse key speed and acceleration. #undef MOUSEKEY_DELAY #define MOUSEKEY_DELAY 0 @@ -24,8 +40,26 @@ // RAMO CUSTOM CONFIG #define RAMO_COMBO_HOLD (TAPPING_TERM) // time to hold to trigger delayed combo +#define RAMO_LINGER_TERM (TAPPING_TERM) // hold time to auto-pair brackets #define RAMO_USE_HD_PROMETHIUM // (Gold is the default) -#define RAMO_ADAPTIVE_TERM (TAPPING_TERM/4) // rolling threshold +// Adaptive rolling window: how long after a keydown the next key can still trigger +// an adaptive. Measured keydown-to-keydown (see prior_keydown_timer / event.time). +// 50ms (TAPPING_TERM/4) = ~240 WPM — roll-only; missed most rules in practice +// 100ms = ~120 WPM — fine for fast rolls (PD, WG), but SFB and +// cross-row rules (KH same-finger, KG inner-column +// stretch) are inherently slower and never fired +// 175ms = ~69 WPM — current. Adaptives exist to fix SFBs and +// scissors, which are *slow by definition*, so the +// window has to clear those motions. +// 229ms (moutis, COMBO_HOLD*1.35) = ~52 WPM +// Collisions to watch at 175ms (same-row rolls in real words): GM in +// "pragma"/"progmem", PD in "update", YB in "keyboard", KG in "background". +// #define RAMO_ADAPTIVE_TERM (TAPPING_TERM/4) // original: 50ms +// #define RAMO_ADAPTIVE_TERM 100 // too tight for SFB rules +#define RAMO_ADAPTIVE_TERM 175 + + +#define RAMO_ADAPT_SHIFT_TERM (TAPPING_TERM) // comma-leader one-shot-shift window (wider than adaptives) // Disable led stuff diff --git a/users/raphaelmor/corne-v4-1-ramo.json b/users/raphaelmor/corne-v4-1-ramo.json new file mode 100644 index 00000000..7a1d194a --- /dev/null +++ b/users/raphaelmor/corne-v4-1-ramo.json @@ -0,0 +1,1604 @@ +[ + { + "backcolor": "#626262", + "name": "Corne v4.1 · raphaelmor Colemak + Hands Down Promethium", + "notes": "# Colemak + HandsDown · Corne v4.1\n\nGenerated by `users/raphaelmor/tools/kle_export.py` from the QMK sources.\nDo not hand-edit — regenerate instead.\n\n## Reaching a layer\n\nEach layer's picture marks the key you **hold** to get there in red, and\nghosts it, because it is not something that layer binds. Where the two\nbases differ the column reads Colemak / HandsDown.\n\n```\n layer hold position\n ----------------------\n Media Esc LH2\n Nav ␣ LH1\n Mouse Tab LH0\n Sym ⏎ / R RH0\n Num ⌫ RH1\n Fun ⌦ / ⏎ RH2\n```\n\n## Combos — 18\n\nA combo is a set of key *positions*, not of letters. Most are limited to\nthe HandsDown base, but 3 are not, so those also fire on\nColemak — at whatever letters sit under those positions.\n\n```\n Colemak HandsDown sends positions\n --------------------------------------------------------------------------------\n S+T T+H th LM2+LM1\n W+P W+M qu — hold past 200 ms to drop the u and leave q LT3+LT1\n R+T N+H Z LM3+LM1\n\n HandsDown base only\n A+E acute dead key (⌥E) — é RM1+RM2\n E+I grave dead key (⌥`) — è RM2+RM3\n A+I circumflex dead key (⌥I) — ê RM1+RM3\n U+O umlaut dead key (⌥U) — ü RB1+RB2\n V+J tilde dead key (⌥N) — ñ LT4+LT0\n S+T ç (⌥C) LM4+LM2\n E+O œ (⌥Q) RM2+RB2\n A+U æ (⌥') RM1+RB1\n V+W+G Shift-Tab LT4+LT3+LT2\n M+J App menu LT1+LT0\n ,+A ; RM0+RM1\n .+\" : RT1+RT3\n /+\" ? RT2+RT3\n .+/ ! RT1+RT2\n Y+B Caps Word on or off RB3+RB4\n```\n\n`key_combos[]` declares 19 entries. One of them repeats a chord\nalready listed above — same positions, same output.\n\n## Adaptive keys — 22\n\nOnly on the HandsDown base. Roll two keys inside 175 ms and the\nsecond one rewrites the pair. Rules that name three letters need the\nthird-from-last key too.\n\n```\n P B D -> PWD (fires on D after P+B)\n PD -> PWD (insert W, D continues) (fires on D after P)\n PF -> PS (fires on F after P)\n KG -> KL (fires on G after K)\n W M G -> WML (fires on G after W+M)\n MG -> LG (del M, L, G continues) (fires on G after M)\n JG -> JPG (insert P, G continues) (fires on G after J)\n WG -> WD (fires on G after W)\n KH -> KN (fires on H after K)\n GJ -> ...g + \"th\" (e.g. \"length\") (fires on J after G)\n WJ -> WL (fires on J after W)\n MK -> LK (del M, L, K continues) (fires on K after M)\n HK -> NK (del H, N, K continues) (fires on K after H)\n GM -> GL (fires on M after G)\n VM -> VL (fires on M after V)\n FP -> SP (del F, S, P continues) (fires on P after F)\n GV -> GT (fires on V after G)\n MV -> LV (del M, L, V continues) (fires on V after M)\n GW -> GD (fires on W after G)\n MW -> MP (fires on W after M)\n YB -> IB (del Y, I, B continues) (fires on B after Y)\n AE -> AU (keeps home row) (fires on E after A)\n```\n\n## Always on\n\n- **Linger brackets** — Tap ( or { for one bracket. Hold past 200 ms for the pair, caret inside: (|) `ramo_linger.c:20`\n- **Caps Word** — Letters get ⇧. Digits, -, _, ⌫ keep it on. Space → _, double space → real space + off. `ramo_linger.c:55`\n- **Shift + ⌫ = ⌦** — Keeps Delete reachable on Hands Down, where it left the thumb row. `ramo_process_record.c:45`\n- **Comma-leader shift** — Hands Down only. Type , then a letter within 200 ms → the comma goes, the letter gets ⇧. `ramo_process_record.c:60`\n- **Combo hold on qu** — Hold the W+M combo past 200 ms and the u is deleted, leaving q. `ramo_combos.c:164`\n- **Base switch** — NAV layer, two left keys. Double-tap only, so a stray press cannot change your base. `ramo_tap_dance.c:11`\n\n## Not drawn\n\nThe board has 46 switches. These 10 are left off:\n\n- Outer pinky column `LT5 LM5 LB5 RT5 RM5 RB5` — `KC_NO` on every layer.\n- Inner ex2 keys `LTA LMA RTA RMA`.\n\nCareful: these hidden positions do have something bound.\n\n- `CMK_LTA` is `LCTL_T(KC_A)`\n- `CMK_RTA` is `LCTL_T(KC_A)`\n- `CMK_LMA` is `LCTL_T(KC_A)`\n- `CMK_RMA` is `LCTL_T(KC_A)`", + "plate": true + }, + [ + { + "x": 2.0, + "c": "#424242", + "t": "#ffffff", + "a": 7, + "f": 3, + "g": false + }, + "F\n\n\n\nHyper", + { + "x": 7.0 + }, + "U\n\n\n\nHyper" + ], + [ + { + "y": -0.9, + "x": 1.0 + }, + "W", + { + "x": 1.0 + }, + "P\n\n\n\nMeh", + { + "x": 5.0 + }, + "L\n\n\n\nMeh", + { + "x": 1.0 + }, + "Y" + ], + [ + { + "y": -0.9, + "x": 4.0 + }, + "G", + { + "x": 3.0 + }, + "J" + ], + [ + { + "y": -0.9 + }, + "Q", + { + "x": 11.0, + "a": 5 + }, + "\"\n'" + ], + [ + { + "y": -0.3, + "x": 2.0, + "a": 7 + }, + "S\n\n\n\n⌘", + { + "x": 7.0 + }, + "E\n\n\n\n⌘" + ], + [ + { + "y": -0.9, + "x": 1.0 + }, + "R\n\n\n\n⌥", + { + "x": 1.0 + }, + "T\n\n\n\n⇧", + { + "x": 5.0 + }, + "N\n\n\n\n⇧", + { + "x": 1.0 + }, + "I\n\n\n\n⌥" + ], + [ + { + "y": -0.9, + "x": 4.0 + }, + "D", + { + "x": 3.0 + }, + "H" + ], + [ + { + "y": -0.9 + }, + "A\n\n\n\n⌃", + { + "x": 11.0 + }, + "O\n\n\n\n⌃" + ], + [ + { + "y": -0.3, + "x": 2.0 + }, + "C", + { + "x": 7.0, + "a": 5 + }, + "<\n," + ], + [ + { + "y": -0.9, + "x": 1.0, + "a": 7 + }, + "X", + { + "x": 1.0 + }, + "V", + { + "x": 5.0 + }, + "M", + { + "x": 1.0, + "a": 5 + }, + ">\n." + ], + [ + { + "y": -0.9, + "x": 4.0, + "a": 7 + }, + "B", + { + "x": 3.0 + }, + "K" + ], + [ + { + "y": -0.9 + }, + "Z", + { + "x": 11.0, + "a": 5 + }, + "?\n/" + ], + [ + { + "y": -0.7, + "x": 5.0, + "a": 7, + "w": 3.0, + "d": true + }, + "Colemak\n\n\n\nbase · CMK" + ], + [ + { + "y": -0.25, + "x": 2.5 + }, + "Esc\n\n\n\nMedia", + "␣\n\n\n\nNav", + "Tab\n\n\n\nMouse", + { + "x": 2.0 + }, + "⏎\n\n\n\nSym", + "⌫\n\n\n\nNum", + "⌦\n\n\n\nFun" + ], + [ + { + "y": 2.25, + "x": 2.0 + }, + "G\n\n\n\nHyper", + { + "x": 7.0, + "a": 5 + }, + "?\n/\n\n\nHyper" + ], + [ + { + "y": -0.9, + "x": 1.0, + "a": 7 + }, + "W", + { + "x": 1.0 + }, + "M\n\n\n\nMeh", + { + "x": 5.0, + "a": 5 + }, + ">\n.\n\n\nMeh", + { + "x": 1.0, + "a": 7 + }, + "\"" + ], + [ + { + "y": -0.9, + "x": 4.0 + }, + "J", + { + "x": 3.0 + }, + "#" + ], + [ + { + "y": -0.9 + }, + "V", + { + "x": 11.0, + "a": 5 + }, + "\"\n'" + ], + [ + { + "y": -0.3, + "x": 2.0, + "a": 7 + }, + "T\n\n\n\n⌘", + { + "x": 7.0 + }, + "E\n\n\n\n⌘" + ], + [ + { + "y": -0.9, + "x": 1.0 + }, + "N\n\n\n\n⌥", + { + "x": 1.0 + }, + "H\n\n\n\n⇧", + { + "x": 5.0 + }, + "A\n\n\n\n⇧", + { + "x": 1.0 + }, + "I\n\n\n\n⌥" + ], + [ + { + "y": -0.9, + "x": 4.0 + }, + "K", + { + "x": 3.0, + "a": 5 + }, + "<\n," + ], + [ + { + "y": -0.9, + "a": 7 + }, + "S\n\n\n\n⌃", + { + "x": 11.0 + }, + "C\n\n\n\n⌃" + ], + [ + { + "y": -0.3, + "x": 2.0 + }, + "D", + { + "x": 7.0 + }, + "O" + ], + [ + { + "y": -0.9, + "x": 1.0 + }, + "P", + { + "x": 1.0 + }, + "L", + { + "x": 5.0 + }, + "U", + { + "x": 1.0 + }, + "Y" + ], + [ + { + "y": -0.9, + "x": 4.0 + }, + "X", + { + "x": 3.0, + "a": 5 + }, + "_\n-" + ], + [ + { + "y": -0.9, + "a": 7 + }, + "F", + { + "x": 11.0 + }, + "B" + ], + [ + { + "y": -0.7, + "x": 5.0, + "w": 3.0, + "d": true + }, + "HandsDown\n\n\n\nbase · HDP" + ], + [ + { + "y": -0.25, + "x": 2.5 + }, + "Esc\n\n\n\nMedia", + "␣\n\n\n\nNav", + "Tab\n\n\n\nMouse", + { + "x": 2.0 + }, + "R\n\n\n\nSym", + "⌫\n\n\n\nNum", + "⏎\n\n\n\nFun" + ], + [ + { + "y": 2.25, + "x": 2.0, + "g": true + }, + "", + { + "x": 7.0, + "g": false + }, + "RGB hue" + ], + [ + { + "y": -0.9, + "x": 1.0, + "g": true + }, + "", + { + "x": 1.0 + }, + "", + { + "x": 5.0, + "g": false + }, + "RGB mode", + { + "x": 1.0 + }, + "RGB sat" + ], + [ + { + "y": -0.9, + "x": 4.0, + "g": true + }, + "", + { + "x": 3.0, + "g": false + }, + "RGB on" + ], + [ + { + "y": -0.9, + "g": true + }, + "", + { + "x": 11.0, + "g": false + }, + "RGB val" + ], + [ + { + "y": -0.3, + "x": 2.0 + }, + "⌘", + { + "x": 7.0 + }, + "Vol−" + ], + [ + { + "y": -0.9, + "x": 1.0 + }, + "⌥", + { + "x": 1.0 + }, + "⇧", + { + "x": 5.0 + }, + "Prev", + { + "x": 1.0 + }, + "Vol+" + ], + [ + { + "y": -0.9, + "x": 4.0, + "g": true + }, + "", + { + "x": 3.0 + }, + "" + ], + [ + { + "y": -0.9, + "g": false + }, + "⌃", + { + "x": 11.0 + }, + "Next" + ], + [ + { + "y": -0.3, + "x": 2.0 + }, + "AltGr", + { + "x": 7.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "x": 1.0 + }, + "", + { + "x": 1.0 + }, + "", + { + "x": 5.0 + }, + "", + { + "x": 1.0 + }, + "" + ], + [ + { + "y": -0.9, + "x": 4.0 + }, + "", + { + "x": 3.0 + }, + "" + ], + [ + { + "y": -0.9 + }, + "", + { + "x": 11.0 + }, + "" + ], + [ + { + "y": -0.7, + "x": 5.0, + "g": false, + "w": 3.0, + "d": true + }, + "Media\n\n\n\nlayer · MED" + ], + [ + { + "y": -0.25, + "x": 2.5, + "c": "#8c5656", + "g": true + }, + "", + { + "c": "#424242" + }, + "", + "", + { + "x": 2.0, + "g": false + }, + "Stop", + "Play", + "Mute" + ], + [ + { + "y": 2.25, + "x": 2.0 + }, + "2× CMK", + { + "x": 7.0 + }, + "⌘C" + ], + [ + { + "y": -0.9, + "x": 1.0, + "g": true + }, + "", + { + "x": 1.0, + "g": false + }, + "2× HDP", + { + "x": 5.0 + }, + "⌘V", + { + "x": 1.0 + }, + "⌘X" + ], + [ + { + "y": -0.9, + "x": 4.0, + "g": true + }, + "", + { + "x": 3.0, + "g": false + }, + "⌘Z" + ], + [ + { + "y": -0.9, + "g": true + }, + "", + { + "x": 11.0, + "g": false + }, + "⇧⌘Z" + ], + [ + { + "y": -0.3, + "x": 2.0 + }, + "⌘", + { + "x": 7.0 + }, + "↓" + ], + [ + { + "y": -0.9, + "x": 1.0 + }, + "⌥", + { + "x": 1.0 + }, + "⇧", + { + "x": 5.0 + }, + "←", + { + "x": 1.0 + }, + "↑" + ], + [ + { + "y": -0.9, + "x": 4.0, + "g": true + }, + "", + { + "x": 3.0, + "g": false + }, + "Caps Word" + ], + [ + { + "y": -0.9 + }, + "⌃", + { + "x": 11.0 + }, + "→" + ], + [ + { + "y": -0.3, + "x": 2.0 + }, + "AltGr", + { + "x": 7.0 + }, + "PgDn" + ], + [ + { + "y": -0.9, + "x": 1.0, + "g": true + }, + "", + { + "x": 1.0 + }, + "", + { + "x": 5.0, + "g": false + }, + "Home", + { + "x": 1.0 + }, + "PgUp" + ], + [ + { + "y": -0.9, + "x": 4.0, + "g": true + }, + "", + { + "x": 3.0, + "g": false + }, + "Ins" + ], + [ + { + "y": -0.9, + "g": true + }, + "", + { + "x": 11.0, + "g": false + }, + "End" + ], + [ + { + "y": -0.7, + "x": 5.0, + "w": 3.0, + "d": true + }, + "Nav\n\n\n\nlayer · NAV" + ], + [ + { + "y": -0.25, + "x": 2.5, + "g": true + }, + "", + { + "c": "#8c5656" + }, + "", + { + "c": "#424242" + }, + "", + { + "x": 2.0, + "g": false + }, + "⏎", + "⌫", + "⌦" + ], + [ + { + "y": 2.25, + "x": 2.0, + "g": true + }, + "", + { + "x": 7.0, + "g": false + }, + "⌘C" + ], + [ + { + "y": -0.9, + "x": 1.0, + "g": true + }, + "", + { + "x": 1.0 + }, + "", + { + "x": 5.0, + "g": false + }, + "⌘V", + { + "x": 1.0 + }, + "⌘X" + ], + [ + { + "y": -0.9, + "x": 4.0, + "g": true + }, + "", + { + "x": 3.0, + "g": false + }, + "⇧⌘Z" + ], + [ + { + "y": -0.9, + "g": true + }, + "", + { + "x": 11.0, + "g": false + }, + "⌘Z" + ], + [ + { + "y": -0.3, + "x": 2.0 + }, + "⌘", + { + "x": 7.0 + }, + "Ms ↓" + ], + [ + { + "y": -0.9, + "x": 1.0 + }, + "⌥", + { + "x": 1.0 + }, + "⇧", + { + "x": 5.0 + }, + "Ms ←", + { + "x": 1.0 + }, + "Ms ↑" + ], + [ + { + "y": -0.9, + "x": 4.0, + "g": true + }, + "", + { + "x": 3.0 + }, + "" + ], + [ + { + "y": -0.9, + "g": false + }, + "⌃", + { + "x": 11.0 + }, + "Ms →" + ], + [ + { + "y": -0.3, + "x": 2.0 + }, + "AltGr", + { + "x": 7.0 + }, + "Wh ↓" + ], + [ + { + "y": -0.9, + "x": 1.0, + "g": true + }, + "", + { + "x": 1.0 + }, + "", + { + "x": 5.0, + "g": false + }, + "Wh ←", + { + "x": 1.0 + }, + "Wh ↑" + ], + [ + { + "y": -0.9, + "x": 4.0, + "g": true + }, + "", + { + "x": 3.0 + }, + "" + ], + [ + { + "y": -0.9 + }, + "", + { + "x": 11.0, + "g": false + }, + "Wh →" + ], + [ + { + "y": -0.7, + "x": 5.0, + "w": 3.0, + "d": true + }, + "Mouse\n\n\n\nlayer · MOS" + ], + [ + { + "y": -0.25, + "x": 2.5, + "g": true + }, + "", + "", + { + "c": "#8c5656" + }, + "", + { + "x": 2.0, + "c": "#424242", + "g": false + }, + "RMB", + "LMB", + "MMB" + ], + [ + { + "y": 2.25, + "x": 2.0 + }, + "*", + { + "x": 7.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "x": 1.0, + "g": false + }, + "&", + { + "x": 1.0 + }, + "(", + { + "x": 5.0, + "g": true + }, + "", + { + "x": 1.0 + }, + "" + ], + [ + { + "y": -0.9, + "x": 4.0, + "g": false + }, + "}", + { + "x": 3.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "g": false + }, + "{", + { + "x": 11.0, + "g": true + }, + "" + ], + [ + { + "y": -0.3, + "x": 2.0, + "g": false + }, + "%", + { + "x": 7.0 + }, + "⌘" + ], + [ + { + "y": -0.9, + "x": 1.0 + }, + "$", + { + "x": 1.0 + }, + "^", + { + "x": 5.0 + }, + "⇧", + { + "x": 1.0 + }, + "⌥" + ], + [ + { + "y": -0.9, + "x": 4.0 + }, + "+", + { + "x": 3.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "g": false + }, + ":", + { + "x": 11.0 + }, + "⌃" + ], + [ + { + "y": -0.3, + "x": 2.0 + }, + "@", + { + "x": 7.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "x": 1.0, + "g": false + }, + "!", + { + "x": 1.0 + }, + "#", + { + "x": 5.0, + "g": true + }, + "", + { + "x": 1.0, + "g": false + }, + "AltGr" + ], + [ + { + "y": -0.9, + "x": 4.0 + }, + "|", + { + "x": 3.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "g": false + }, + "~", + { + "x": 11.0, + "g": true + }, + "" + ], + [ + { + "y": -0.7, + "x": 5.0, + "g": false, + "w": 3.0, + "d": true + }, + "Sym\n\n\n\nlayer · SYM" + ], + [ + { + "y": -0.25, + "x": 2.5 + }, + "(", + ")", + "_", + { + "x": 2.0, + "c": "#8c5656", + "g": true + }, + "", + { + "c": "#424242" + }, + "", + "" + ], + [ + { + "y": 2.25, + "x": 2.0, + "a": 5, + "g": false + }, + "*\n8", + { + "x": 7.0, + "a": 7, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "x": 1.0, + "a": 5, + "g": false + }, + "&\n7", + { + "x": 1.0 + }, + "(\n9", + { + "x": 5.0, + "a": 7, + "g": true + }, + "", + { + "x": 1.0 + }, + "" + ], + [ + { + "y": -0.9, + "x": 4.0, + "a": 5, + "g": false + }, + "}\n]", + { + "x": 3.0, + "a": 7, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "a": 5, + "g": false + }, + "{\n[", + { + "x": 11.0, + "a": 7, + "g": true + }, + "" + ], + [ + { + "y": -0.3, + "x": 2.0, + "a": 5, + "g": false + }, + "%\n5", + { + "x": 7.0, + "a": 7 + }, + "⌘" + ], + [ + { + "y": -0.9, + "x": 1.0, + "a": 5 + }, + "$\n4", + { + "x": 1.0 + }, + "^\n6", + { + "x": 5.0, + "a": 7 + }, + "⇧", + { + "x": 1.0 + }, + "⌥" + ], + [ + { + "y": -0.9, + "x": 4.0, + "a": 5 + }, + "+\n=", + { + "x": 3.0, + "a": 7, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "a": 5, + "g": false + }, + ":\n;", + { + "x": 11.0, + "a": 7 + }, + "⌃" + ], + [ + { + "y": -0.3, + "x": 2.0, + "a": 5 + }, + "@\n2", + { + "x": 7.0, + "a": 7, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "x": 1.0, + "a": 5, + "g": false + }, + "!\n1", + { + "x": 1.0 + }, + "#\n3", + { + "x": 5.0, + "a": 7, + "g": true + }, + "", + { + "x": 1.0, + "g": false + }, + "AltGr" + ], + [ + { + "y": -0.9, + "x": 4.0, + "a": 5 + }, + "|\n\\", + { + "x": 3.0, + "a": 7, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "a": 5, + "g": false + }, + "~\n`", + { + "x": 11.0, + "a": 7, + "g": true + }, + "" + ], + [ + { + "y": -0.7, + "x": 5.0, + "g": false, + "w": 3.0, + "d": true + }, + "Num\n\n\n\nlayer · NUM" + ], + [ + { + "y": -0.25, + "x": 2.5, + "a": 5 + }, + ">\n.", + ")\n0", + "_\n-", + { + "x": 2.0, + "a": 7, + "g": true + }, + "", + { + "c": "#8c5656" + }, + "", + { + "c": "#424242" + }, + "" + ], + [ + { + "y": 2.25, + "x": 2.0, + "g": false + }, + "F8", + { + "x": 7.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "x": 1.0, + "g": false + }, + "F7", + { + "x": 1.0 + }, + "F9", + { + "x": 5.0, + "g": true + }, + "", + { + "x": 1.0 + }, + "" + ], + [ + { + "y": -0.9, + "x": 4.0, + "g": false + }, + "PrtSc", + { + "x": 3.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "g": false + }, + "F12", + { + "x": 11.0, + "g": true + }, + "" + ], + [ + { + "y": -0.3, + "x": 2.0, + "g": false + }, + "F5", + { + "x": 7.0 + }, + "⌘" + ], + [ + { + "y": -0.9, + "x": 1.0 + }, + "F4", + { + "x": 1.0 + }, + "F6", + { + "x": 5.0 + }, + "⇧", + { + "x": 1.0 + }, + "⌥" + ], + [ + { + "y": -0.9, + "x": 4.0 + }, + "ScrLk", + { + "x": 3.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "g": false + }, + "F11", + { + "x": 11.0 + }, + "⌃" + ], + [ + { + "y": -0.3, + "x": 2.0 + }, + "F2", + { + "x": 7.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "x": 1.0, + "g": false + }, + "F1", + { + "x": 1.0 + }, + "F3", + { + "x": 5.0, + "g": true + }, + "", + { + "x": 1.0, + "g": false + }, + "AltGr" + ], + [ + { + "y": -0.9, + "x": 4.0 + }, + "Pause", + { + "x": 3.0, + "g": true + }, + "" + ], + [ + { + "y": -0.9, + "g": false + }, + "F10", + { + "x": 11.0, + "g": true + }, + "" + ], + [ + { + "y": -0.7, + "x": 5.0, + "g": false, + "w": 3.0, + "d": true + }, + "Fun\n\n\n\nlayer · FUN" + ], + [ + { + "y": -0.25, + "x": 2.5 + }, + "▤", + "␣", + "Tab", + { + "x": 2.0, + "g": true + }, + "", + "", + { + "c": "#8c5656" + }, + "" + ] +] diff --git a/users/raphaelmor/hd-colemak.html b/users/raphaelmor/hd-colemak.html new file mode 100644 index 00000000..02cb550c --- /dev/null +++ b/users/raphaelmor/hd-colemak.html @@ -0,0 +1,358 @@ + + + + + +Corne layers — current · full HD · proposed hybrid + + + +
+
+
+ + + +
+
+
+
+
+
+
+
+ +

Proposed hybrid — Miryoku chassis + HD engine

+

+ +
+ alpha + mod (hold) + layer / switch + semantic / editing + symbol + number + mouse + media + RGB/cfg + NEW + CHG + +hold + removed +
+ +
+ +
+

Engine features — code, not layout (✅ implemented)

+
+

✅ Adaptives — Hands Down base only

Full PM set (~20 rules): PF→PS, KG→KL, KH→KN, GJ→"th", MK→LK, GV→GT, AE→AU… Gated so they run only on the HD base.

ramo_adaptive.c (implemented)

+

✅ Combos

qu (P+L), z (N+H), and now th (T+H) live. More digraphs/symbols easy to add.

ramo_combos.c (implemented)

+

✅ Linger brackets — SYM

Hold ( or { → auto-pair, caret between: (|). Tap = single. ([ and " not yet — need a SYM home.)

ramo_linger.c · RAMO_LINGER_TERM

+

✅ Smart caps-word

Shifts letters, keeps digits/-/_, turns space→_, auto-exits on punctuation.

ramo_linger.c (implemented)

+
+
+ + + + + + diff --git a/users/raphaelmor/layers/CMK-defs.h b/users/raphaelmor/layers/CMK-defs.h index 1c6e16f5..ee36e572 100644 --- a/users/raphaelmor/layers/CMK-defs.h +++ b/users/raphaelmor/layers/CMK-defs.h @@ -1,3 +1,13 @@ +/* + * CMK-defs.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once // Override keys with HRM and Layer keys diff --git a/users/raphaelmor/layers/FUN-defs.h b/users/raphaelmor/layers/FUN-defs.h index 8a1b9dbf..ae3e56c0 100644 --- a/users/raphaelmor/layers/FUN-defs.h +++ b/users/raphaelmor/layers/FUN-defs.h @@ -1,3 +1,13 @@ +/* + * FUN-defs.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once // Override keys with HRM and Layer keys diff --git a/users/raphaelmor/layers/HDG-defs.h b/users/raphaelmor/layers/HDG-defs.h index 69388d57..1db5bd14 100644 --- a/users/raphaelmor/layers/HDG-defs.h +++ b/users/raphaelmor/layers/HDG-defs.h @@ -1,3 +1,13 @@ +/* + * HDG-defs.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once // Override keys with HRM and Layer keys diff --git a/users/raphaelmor/layers/HDP-defs.h b/users/raphaelmor/layers/HDP-defs.h index 1778b173..f6b3ab33 100644 --- a/users/raphaelmor/layers/HDP-defs.h +++ b/users/raphaelmor/layers/HDP-defs.h @@ -1,3 +1,13 @@ +/* + * HDP-defs.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once // Let's place these HD keycodes on the keymap @@ -13,32 +23,34 @@ // ╰─────────────╯ ╰─────────────╯ // // -// Base (alpha) Layer Hands Down Promethium (HRMs /+ thumb mods) +// Base (alpha) Layer Hands Down Promethium — canonical arrangement (moutis) // ╭───────────────────────────────╮ ╭───────────────────────────────╮ -// │ ESC F P D L X LTA │ │ RTA ; U O Y B Z │ -// │ TAB S N T H K LMA │ │ RMA , A E I C Q │ -// │ \ V W G M J ╭─────╯ ╰─────╮ - . ' = / ENT │ -// ╰───────────╮ ESC TAB R │ │ SPC ENT BSP ╭───────────╯ +// │ · V W G M J LTA │ │ RTA # . / " ' · │ +// │ · S N T H K LMA │ │ RMA , A E I C · │ +// │ · F P D L X ╭─────╯ ╰─────╮ - U O Y B · │ +// ╰───────────╮ ESC SPC TAB │ │ R BSP ENT ╭───────────╯ // ╰─────────────╯ ╰─────────────╯ +// Home-row mods (my GACS): pinky ⌃ · ring ⌥ · middle ⌘ · index ⇧ +// Kept extras: Hyper on D/O, Meh on L/U · outer columns blank · Option-A thumbs // -#define HDP_LT5 KC_TAB -#define HDP_LT4 KC_F -#define HDP_LT3 KC_P -#define HDP_LT2 HYPR_T(KC_D) -#define HDP_LT1 MEH_T(KC_L) -#define HDP_LT0 KC_X +#define HDP_LT5 KC_NO // outer column unused +#define HDP_LT4 KC_V +#define HDP_LT3 KC_W +#define HDP_LT2 HYPR_T(KC_G) +#define HDP_LT1 MEH_T(KC_M) +#define HDP_LT0 KC_J #define HDP_LTA KC_NO // TODO: should be a real key once a use is found #define HDP_RTA KC_NO // TODO: should be a real key once a use is found -#define HDP_RT0 KC_SCLN -#define HDP_RT1 MEH_T(KC_U) -#define HDP_RT2 HYPR_T(KC_O) -#define HDP_RT3 KC_Y -#define HDP_RT4 KC_B -#define HDP_RT5 KC_Z +#define HDP_RT0 KC_HASH +#define HDP_RT1 MEH_T(KC_DOT) +#define HDP_RT2 HYPR_T(KC_SLSH) +#define HDP_RT3 KC_DQUO +#define HDP_RT4 KC_QUOT +#define HDP_RT5 KC_NO // outer column unused (Z via N+H combo) -#define HDP_LM5 KC_TAB +#define HDP_LM5 KC_NO // outer column unused (Tab is on thumb) #define HDP_LM4 LCTL_T(KC_S) #define HDP_LM3 LALT_T(KC_N) #define HDP_LM2 LGUI_T(KC_T) @@ -52,29 +64,32 @@ #define HDP_RM2 RGUI_T(KC_E) #define HDP_RM3 RALT_T(KC_I) #define HDP_RM4 RCTL_T(KC_C) -#define HDP_RM5 KC_Q +#define HDP_RM5 KC_NO // outer column unused (Q via W+M combo) -#define HDP_LB5 KC_BSLS -#define HDP_LB4 KC_V -#define HDP_LB3 KC_W -#define HDP_LB2 KC_G -#define HDP_LB1 KC_M -#define HDP_LB0 KC_J +#define HDP_LB5 KC_NO // outer column unused (\ is on NUM layer) +#define HDP_LB4 KC_F +#define HDP_LB3 KC_P +#define HDP_LB2 KC_D +#define HDP_LB1 KC_L +#define HDP_LB0 KC_X #define HDP_RB0 KC_MINS -#define HDP_RB1 KC_DOT -#define HDP_RB2 KC_QUOT -#define HDP_RB3 KC_EQL -#define HDP_RB4 KC_SLSH -#define HDP_RB5 KC_ENT +#define HDP_RB1 KC_U +#define HDP_RB2 KC_O +#define HDP_RB3 KC_Y +#define HDP_RB4 KC_B +#define HDP_RB5 KC_NO // outer column unused (Enter is on thumb) //Primary Thumbs 1-3 (others are unique to the board) -#define HDP_LH2 LT(L_MEDIA, KC_ESC) -#define HDP_LH1 LT(L_NAV, KC_TAB) -#define HDP_LH0 LT(L_MOUSE, KC_R) -#define HDP_RH0 LT(L_SYM, KC_SPC) -#define HDP_RH1 LT(L_NUM, KC_ENT) -#define HDP_RH2 LT(L_FUN, KC_BSPC) +// Option A: thumbs re-aligned to Colemak. Left hand now identical to Colemak; +// only R (inner-right, Promethium's signature) and Enter (outer-right) differ. +// Colemak's Del (outer-right) is not on a thumb here → use Shift+Bksp (see ramo_process_record.c). +#define HDP_LH2 LT(L_MEDIA, KC_ESC) // Esc (= Colemak) +#define HDP_LH1 LT(L_NAV, KC_SPC) // Space (= Colemak) [was Tab] +#define HDP_LH0 LT(L_MOUSE, KC_TAB) // Tab (= Colemak) [was R] +#define HDP_RH0 LT(L_SYM, KC_R) // R (Promethium R-on-thumb) [was Space] +#define HDP_RH1 LT(L_NUM, KC_BSPC) // Bksp (= Colemak) [was Enter] +#define HDP_RH2 LT(L_FUN, KC_ENT) // Enter (Colemak has Del here) [was Bksp] #define HDP_RGB_LT5 BLACK #define HDP_RGB_LT4 ORANGE @@ -131,9 +146,13 @@ #define HDP_RGB_RH2 MAGENTA -// type p + l to type q +// type w + m to type qu (canonical PM position) #define HD_Q_keys HDP_LT3, HDP_LT1 // type n + h to type z #define HD_Z_keys HDP_LM3, HDP_LM1 +// Enable the H-digraph roll-combo on Promethium +#define USE_HD_H_DIGRAPH_COMBO +#define HD_Th_keys HDP_LM2, HDP_LM1 // T + H -> "th" + diff --git a/users/raphaelmor/layers/MED-defs.h b/users/raphaelmor/layers/MED-defs.h index f03dbb07..fd4e08c5 100644 --- a/users/raphaelmor/layers/MED-defs.h +++ b/users/raphaelmor/layers/MED-defs.h @@ -1,3 +1,13 @@ +/* + * MED-defs.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once // Override keys with HRM and Layer keys @@ -26,11 +36,11 @@ #define MED_LTA KC_NO // TODO: should be a real key once a use is found #define MED_RTA KC_NO // TODO: should be a real key once a use is found -#define MED_RT0 RGB_TOG -#define MED_RT1 RGB_MOD -#define MED_RT2 RGB_HUI -#define MED_RT3 RGB_SAI -#define MED_RT4 RGB_VAI +#define MED_RT0 RM_TOGG +#define MED_RT1 RM_NEXT +#define MED_RT2 RM_HUEU +#define MED_RT3 RM_SATU +#define MED_RT4 RM_VALU #define MED_RT5 MED_NO #define MED_LM5 NAV_NO diff --git a/users/raphaelmor/layers/MOS-defs.h b/users/raphaelmor/layers/MOS-defs.h index 7c02dc4b..0e4976c5 100644 --- a/users/raphaelmor/layers/MOS-defs.h +++ b/users/raphaelmor/layers/MOS-defs.h @@ -1,3 +1,13 @@ +/* + * MOS-defs.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once // Override keys with HRM and Layer keys @@ -42,10 +52,10 @@ #define MOS_RMA KC_NO // TODO: should be a real key once a use is found #define MOS_RM0 MOS_NO -#define MOS_RM1 KC_MS_L -#define MOS_RM2 KC_MS_D -#define MOS_RM3 KC_MS_U -#define MOS_RM4 KC_MS_R +#define MOS_RM1 MS_LEFT +#define MOS_RM2 MS_DOWN +#define MOS_RM3 MS_UP +#define MOS_RM4 MS_RGHT #define MOS_RM5 MOS_NO #define MOS_LB5 MOS_NO @@ -56,10 +66,10 @@ #define MOS_LB0 MOS_NO #define MOS_RB0 MOS_NO -#define MOS_RB1 KC_WH_L -#define MOS_RB2 KC_WH_D -#define MOS_RB3 KC_WH_U -#define MOS_RB4 KC_WH_R +#define MOS_RB1 MS_WHLL +#define MOS_RB2 MS_WHLD +#define MOS_RB3 MS_WHLU +#define MOS_RB4 MS_WHLR #define MOS_RB5 MOS_NO //Primary Thumbs 1-3 (others are unique to the board) @@ -67,9 +77,9 @@ #define MOS_LH1 MOS_NO #define MOS_LH0 MOS_NO -#define MOS_RH0 KC_BTN2 -#define MOS_RH1 KC_BTN1 -#define MOS_RH2 KC_BTN3 +#define MOS_RH0 MS_BTN2 +#define MOS_RH1 MS_BTN1 +#define MOS_RH2 MS_BTN3 diff --git a/users/raphaelmor/layers/NAV-defs.h b/users/raphaelmor/layers/NAV-defs.h index 835cffdf..16a82ce9 100644 --- a/users/raphaelmor/layers/NAV-defs.h +++ b/users/raphaelmor/layers/NAV-defs.h @@ -1,3 +1,13 @@ +/* + * NAV-defs.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once // Override keys with HRM and Layer keys diff --git a/users/raphaelmor/layers/NUM-defs.h b/users/raphaelmor/layers/NUM-defs.h index e6146258..fdaddb37 100644 --- a/users/raphaelmor/layers/NUM-defs.h +++ b/users/raphaelmor/layers/NUM-defs.h @@ -1,3 +1,13 @@ +/* + * NUM-defs.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once // Override keys with HRM and Layer keys diff --git a/users/raphaelmor/layers/SYM-defs.h b/users/raphaelmor/layers/SYM-defs.h index affa19ed..2ad0c23e 100644 --- a/users/raphaelmor/layers/SYM-defs.h +++ b/users/raphaelmor/layers/SYM-defs.h @@ -1,3 +1,13 @@ +/* + * SYM-defs.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once // Override keys with HRM and Layer keys diff --git a/users/raphaelmor/ramo_adaptive.c b/users/raphaelmor/ramo_adaptive.c index 84804530..e94c8083 100644 --- a/users/raphaelmor/ramo_adaptive.c +++ b/users/raphaelmor/ramo_adaptive.c @@ -1,61 +1,139 @@ +/* + * ramo_adaptive.c + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ bool process_adaptive_key(uint16_t keycode, const keyrecord_t *record) { bool result = true; - if (timer_elapsed(prior_keydown_timer) > RAMO_ADAPTIVE_TERM) { // outside adaptive threshhold - prior_keycode = prior_keydown_timer = 0; // turn off Adaptives. - return true; // no adaptive conditions, so return. + // Adaptives are tuned to Hands Down Promethium's physical rolls, + // so only run them while the Hands Down base is the active default layer. + // (On Colemak they'd "fix" bigrams that aren't even neighbors.) + if (get_highest_layer(default_layer_state) != L_HANDSDOWN) { + return true; } - // We're within Adaptive time window + + // Window is measured keydown-to-keydown using record->event.time, NOT + // timer_read(). For a mod-tap/layer-tap, process_record_user only sees the + // record once the tap resolves (on release, or after TAPPING_TERM), so + // timer_read() here would measure prior-keydown → *this key's release* and + // charge the hold time against the window. event.time is stamped when the + // key was physically pressed, so 12 mod-tap keys (G M . / + both home rows) + // get the same window as plain keys. + if (TIMER_DIFF_16(record->event.time, prior_keydown_timer) > RAMO_ADAPTIVE_TERM) { // outside adaptive window + prior_keycode = preprior_keycode = prior_keydown_timer = 0; // turn off adaptives, reset + return true; // no adaptive conditions, continue processing. + } + // We're within the adaptive rolling window. + switch (keycode) { + + // ---------------- left hand ---------------- + case KC_D: + switch (prior_keycode) { + case KC_B: + if (preprior_keycode == KC_P) { // P B D -> PWD + RAMO_TAP(KC_BSPC); RAMO_TAP(KC_W); // del B, W, D continues + } + break; + case KC_P: RAMO_TAP(KC_W); break; // PD -> PWD (insert W, D continues) + } + break; + case KC_F: + switch (prior_keycode) { + case KC_P: RAMO_TAP(KC_S); result = false; break; // PF -> PS + } + break; + case KC_G: + switch (prior_keycode) { + case KC_K: RAMO_TAP(KC_L); result = false; break; // KG -> KL + case KC_M: + if (preprior_keycode == KC_W) { // W M G -> WML + RAMO_TAP(KC_L); result = false; // G becomes L + break; + } + RAMO_TAP(KC_BSPC); RAMO_TAP(KC_L); break; // MG -> LG (del M, L, G continues) + case KC_J: RAMO_TAP(KC_P); break; // JG -> JPG (insert P, G continues) + case KC_W: RAMO_TAP(KC_D); result = false; break; // WG -> WD + } + break; + case KC_H: + switch (prior_keycode) { + case KC_K: RAMO_TAP(KC_N); result = false; break; // KH -> KN + } + break; + case KC_J: + switch (prior_keycode) { + case KC_G: RAMO_SEND_STRING("th"); result = false; break; // GJ -> ...g + "th" (e.g. "length") + // V J removed: V+J is the ˜ tilde combo (HD_tilde_combo), same + // unreachability as W M above. W J kept — no combo on those keys. + case KC_W: RAMO_TAP(KC_L); result = false; break; // WJ -> WL + } + break; + case KC_K: + switch (prior_keycode) { + case KC_M: RAMO_TAP(KC_BSPC); RAMO_TAP(KC_L); break; // MK -> LK (del M, L, K continues) + case KC_H: RAMO_TAP(KC_BSPC); RAMO_TAP(KC_N); break; // HK -> NK (del H, N, K continues) + } + break; + case KC_M: + switch (prior_keycode) { + case KC_G: // GM -> GL + case KC_V: RAMO_TAP(KC_L); result = false; break; // VM -> VL + // W M (and the 3-key M W M) removed: W+M is the `qu` combo + // (HD_Q_keys). Fast enough to roll for the adaptive is also fast + // enough to land inside COMBO_TERM, so `qu` won and the adaptive + // was unreachable. Kept the combo — Q needs a home. + } + break; + case KC_P: + switch (prior_keycode) { + case KC_F: RAMO_TAP(KC_BSPC); RAMO_TAP(KC_S); break; // FP -> SP (del F, S, P continues) + } + break; + case KC_V: + switch (prior_keycode) { + case KC_G: RAMO_TAP(KC_T); result = false; break; // GV -> GT + case KC_M: RAMO_TAP(KC_BSPC); RAMO_TAP(KC_L); break; // MV -> LV (del M, L, V continues) + } + break; + case KC_W: + switch (prior_keycode) { + case KC_G: RAMO_TAP(KC_D); result = false; break; // GW -> GD + case KC_M: RAMO_TAP(KC_P); result = false; break; // MW -> MP + } + break; + + // ---------------- right hand ---------------- + case KC_B: + switch (prior_keycode) { + case KC_Y: RAMO_TAP(KC_BSPC); RAMO_TAP(KC_I); break; // YB -> IB (del Y, I, B continues) + } + break; case KC_E: switch (prior_keycode) { - case KC_A: // "AE" yields "AU" (8x more common) keeping it on home row - tap_code(KC_U); - result = false; // done. - break; + case KC_A: RAMO_TAP(KC_U); result = false; break; // AE -> AU (keeps home row) } break; } - // true: did not trigger adaptive. Continue processing. - // false: did trigger adaptive. Do not continue. + // true: no adaptive triggered, continue processing. + // false: adaptive replaced the key, stop processing. return result; } + /* -PBD -> PWD + Skipped by choice (low value on this HDP arrangement): + P/B/S then L -> unshifted L -PF -> PS - -KG -> KL -WMG -> WML -MG -> LG -JG -> JPG -WG -> WD - -KH -> KN - -GJ -> GTH -VJ -> VL -WJ -> WL - -MK -> LK -HK -> NK - -GM -> GL -VM -> VL -MWM -> MWL -(.)WM -> (.)LM - -FP -> SP - -GV -> GT -MV -> LV - -GW -> GD -MW -> MP - -YB -> IB -AE -> AU + Removed — unreachable, the same two keys are a combo (combos resolve in + pre_process_record_quantum, before this engine ever runs): + W M -> lm and M W M -> MWL (W+M is the `qu` combo) + V J -> VL (V+J is the ˜ tilde combo) */ diff --git a/users/raphaelmor/ramo_combos.c b/users/raphaelmor/ramo_combos.c index 252fa6c7..52a8cbb6 100644 --- a/users/raphaelmor/ramo_combos.c +++ b/users/raphaelmor/ramo_combos.c @@ -1,3 +1,13 @@ +/* + * ramo_combos.c + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + // combos #ifdef USE_HD_H_DIGRAPH_COMBO const uint16_t PROGMEM HD_Th_combo[] = {HD_Th_keys, COMBO_END}; // TYPE "th" in hands down @@ -6,6 +16,33 @@ const uint16_t PROGMEM HD_Qu_combo[] = {HD_Q_keys, COMBO_END}; // TYPE "qu" (Lin const uint16_t PROGMEM HD_Z_combo[] = {HD_Z_keys, COMBO_END}; // TYPE "z" const uint16_t PROGMEM CMK_Th_combo[] = {CMK_Th_keys, COMBO_END}; // TYPE "th" in colemak +// --- Hands Down base only (gated in combo_should_trigger) ------------------- +// All at moutis' CANONICAL PM positions (layout now matches). Diacritics/ligatures +// assume the stock US Mac layout (⌥-dead-keys). +// 2g diacritics — tap the combo (dead key / glyph), then type the letter: +const uint16_t PROGMEM HD_acut_combo[] = {HDP_RM1, HDP_RM2, COMBO_END}; // A+E → ´ (⌥E) é +const uint16_t PROGMEM HD_grv_combo[] = {HDP_RM2, HDP_RM3, COMBO_END}; // E+I → ` (⌥`) è +const uint16_t PROGMEM HD_circ_combo[] = {HDP_RM1, HDP_RM3, COMBO_END}; // A+I → ˆ (⌥I) ê +const uint16_t PROGMEM HD_umla_combo[] = {HDP_RB1, HDP_RB2, COMBO_END}; // U+O → ¨ (⌥U) ü +const uint16_t PROGMEM HD_tilde_combo[] = {HDP_LT4, HDP_LT0, COMBO_END}; // V+J → ˜ (⌥N) ñ +const uint16_t PROGMEM HD_cedil_combo[] = {HDP_LM4, HDP_LM2, COMBO_END}; // S+T → ç (⌥C direct) +// 2f ligatures: +const uint16_t PROGMEM HD_oe_combo[] = {HDP_RM2, HDP_RB2, COMBO_END}; // E+O → œ (⌥Q) +const uint16_t PROGMEM HD_ae_combo[] = {HDP_RM1, HDP_RB1, COMBO_END}; // A+U → æ (⌥') +// 2c whitespace (Tab/Enter already on thumbs → only these): +const uint16_t PROGMEM HD_stab_combo[] = {HDP_LT4, HDP_LT3, HDP_LT2, COMBO_END}; // V+W+G → Shift-Tab +const uint16_t PROGMEM HD_app_combo[] = {HDP_LT1, HDP_LT0, COMBO_END}; // M+J → App menu +// 2e symbols — canonical top-right punctuation cluster (# . / " '): +const uint16_t PROGMEM HD_scln_combo[] = {HDP_RM0, HDP_RM1, COMBO_END}; // ,+A → ; +const uint16_t PROGMEM HD_coln_combo[] = {HDP_RT1, HDP_RT3, COMBO_END}; // .+" → : +const uint16_t PROGMEM HD_exlm_combo[] = {HDP_RT1, HDP_RT2, COMBO_END}; // .+/ → ! +const uint16_t PROGMEM HD_ques_combo[] = {HDP_RT2, HDP_RT3, COMBO_END}; // /+" → ? +// 2h caps word — canonical position (moutis: H_CAPW_combo on HD_RB3+HD_RB4). +// Same two keys as the YB → IB adaptive, but combos are resolved in +// pre_process_record_quantum before process_record_user ever runs: a +// simultaneous press is a combo, a sequential roll is the adaptive. +const uint16_t PROGMEM HD_capw_combo[] = {HDP_RB3, HDP_RB4, COMBO_END}; // Y+B → Caps Word + enum ramo_combos { NO_COMBO = 0, // Sentinel value (0) to signify no combo is currently pressed #ifdef USE_HD_H_DIGRAPH_COMBO @@ -14,6 +51,12 @@ enum ramo_combos { HD_COMBO_QU, HD_COMBO_Z, CMK_COMBO_Th, + // Hands Down base only (contiguous — see combo_should_trigger) + HD_COMBO_ACUT, HD_COMBO_GRV, HD_COMBO_CIRC, HD_COMBO_UMLA, HD_COMBO_TILDE, HD_COMBO_CEDIL, + HD_COMBO_OE, HD_COMBO_AE, + HD_COMBO_STAB, HD_COMBO_APP, + HD_COMBO_SCLN, HD_COMBO_COLN, HD_COMBO_QUES, HD_COMBO_EXLM, + HD_COMBO_CAPW, // keep last: combo_should_trigger gates ACUT ... CAPW }; combo_t key_combos[] = { @@ -25,8 +68,34 @@ combo_t key_combos[] = { [HD_COMBO_Z] = COMBO(HD_Z_combo, KC_Z), // Colemak combos [CMK_COMBO_Th] = COMBO_ACTION(CMK_Th_combo), + // Hands Down base only (gated in combo_should_trigger) + [HD_COMBO_ACUT] = COMBO(HD_acut_combo, A(KC_E)), + [HD_COMBO_GRV] = COMBO(HD_grv_combo, A(KC_GRV)), + [HD_COMBO_CIRC] = COMBO(HD_circ_combo, A(KC_I)), + [HD_COMBO_UMLA] = COMBO(HD_umla_combo, A(KC_U)), + [HD_COMBO_TILDE] = COMBO(HD_tilde_combo, A(KC_N)), + [HD_COMBO_CEDIL] = COMBO(HD_cedil_combo, A(KC_C)), + [HD_COMBO_OE] = COMBO(HD_oe_combo, A(KC_Q)), + [HD_COMBO_AE] = COMBO(HD_ae_combo, A(KC_QUOT)), + [HD_COMBO_STAB] = COMBO(HD_stab_combo, S(KC_TAB)), + [HD_COMBO_APP] = COMBO(HD_app_combo, KC_APP), + [HD_COMBO_SCLN] = COMBO(HD_scln_combo, KC_SCLN), + [HD_COMBO_COLN] = COMBO(HD_coln_combo, KC_COLN), + [HD_COMBO_QUES] = COMBO(HD_ques_combo, KC_QUES), + [HD_COMBO_EXLM] = COMBO(HD_exlm_combo, KC_EXLM), + [HD_COMBO_CAPW] = COMBO(HD_capw_combo, CW_TOGG), }; +// Restrict the added diacritic/ligature/symbol/whitespace combos to the Hands +// Down base; qu/z/th keep their existing (both-base) behavior. +bool combo_should_trigger(uint16_t combo_index, combo_t *combo, uint16_t keycode, keyrecord_t *record) { + switch (combo_index) { + case HD_COMBO_ACUT ... HD_COMBO_CAPW: + return get_highest_layer(default_layer_state) == L_HANDSDOWN; + } + return true; +} + uint8_t current_combo = 0; // for combo actions to hold before triggering uint8_t ramo_process_combo_pressed(uint16_t combo_index); @@ -57,23 +126,23 @@ uint8_t ramo_process_combo_pressed(uint16_t combo_index) { #endif case CMK_COMBO_Th: if (is_caps_word_on()) { - tap_code16(S(KC_T)); // send "T" - tap_code16(S(KC_H)); // send "H" + RAMO_TAP16(S(KC_T)); // send "T" + RAMO_TAP16(S(KC_H)); // send "H" } else { - tap_code(KC_T); // send "T" honoring caps + RAMO_TAP(KC_T); // send "T" honoring caps unregister_mods(MOD_MASK_SHIFT); - tap_code(KC_H); // send "h" honoring CAPSLK state + RAMO_TAP(KC_H); // send "h" honoring CAPSLK state } break; case HD_COMBO_QU: - println("RAMO: Pressed N + H to type qu"); + println("RAMO: W + M -> qu"); if (is_caps_word_on()) { - tap_code16(S(KC_Q)); - tap_code16(S(KC_U)); + RAMO_TAP16(S(KC_Q)); + RAMO_TAP16(S(KC_U)); } else { - tap_code16(KC_Q); + RAMO_TAP16(KC_Q); unregister_mods(MOD_MASK_SHIFT); - tap_code16(KC_U); + RAMO_TAP16(KC_U); } return combo_index; // if held, delete the 'u' in matrix_scan_user_process_combo } @@ -88,7 +157,7 @@ void ramo_process_combo_released(uint16_t combo_index) { case CMK_COMBO_Th: break; case HD_COMBO_QU: - println("RAMO: Released P + L to type qu"); + println("RAMO: released W + M (qu)"); } } @@ -99,7 +168,7 @@ void combo_check_for_linger(void) { clear_mods(); switch (current_combo) { case HD_COMBO_QU: - tap_code16(KC_BSPC); // held, so delete u + RAMO_TAP16(KC_BSPC); // held, so delete u break; } current_combo = NO_COMBO; diff --git a/users/raphaelmor/ramo_layers.h b/users/raphaelmor/ramo_layers.h index 97307d15..fb3f6f8d 100644 --- a/users/raphaelmor/ramo_layers.h +++ b/users/raphaelmor/ramo_layers.h @@ -1,3 +1,13 @@ +/* + * ramo_layers.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #include "layers/CMK-defs.h" #ifdef RAMO_USE_HD_PROMETHIUM #include "layers/HDP-defs.h" diff --git a/users/raphaelmor/ramo_ledmap.c b/users/raphaelmor/ramo_ledmap.c index 2f0bf1e7..c0135bd2 100644 --- a/users/raphaelmor/ramo_ledmap.c +++ b/users/raphaelmor/ramo_ledmap.c @@ -1,3 +1,12 @@ +/* + * ramo_ledmap.c + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ // Macro that defines a ledmap per layer (e.g. CMK_RGB_LT5, NAV_RGB_LB1) // Then each layer simply has to define the derired color per key diff --git a/users/raphaelmor/ramo_linger.c b/users/raphaelmor/ramo_linger.c new file mode 100644 index 00000000..4c590061 --- /dev/null +++ b/users/raphaelmor/ramo_linger.c @@ -0,0 +1,107 @@ +/* + * ramo_linger.c + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + +// Linger brackets + smart Caps Word (casemods) +// +// Linger: tap ( or { for a normal single bracket; HOLD it past RAMO_LINGER_TERM +// and it auto-closes with the caret between the pair: (|) {|} . +// We consume the press and TAP the open bracket immediately (so the key is never +// held in the HID report → no OS auto-repeat), then add the closing bracket + Left +// once the hold threshold is reached. No event deferral, no roll-over ordering bugs. + +// returns true if it consumed the key press (caller should stop processing). +bool linger_track(uint16_t keycode, keyrecord_t *record) { + if (record->event.pressed) { + uint16_t close = 0; + switch (keycode) { + case KC_LPRN: close = KC_RPRN; break; + case KC_LCBR: close = KC_RCBR; break; + } + if (close) { + RAMO_TAP16(keycode); // type the open bracket now (tap → no auto-repeat) + linger_key = keycode; + linger_close = close; + linger_ktimer = timer_read(); + linger_fired = false; + return true; // consumed + } + linger_key = 0; // any other key press cancels a pending linger + } else if (keycode == linger_key) { + linger_key = 0; // released before/after threshold + } + return false; +} + +void linger_scan(void) { + if (linger_key && !linger_fired && timer_elapsed(linger_ktimer) > RAMO_LINGER_TERM) { + RAMO_TAP16(linger_close); // add the closing bracket + RAMO_TAP16(KC_LEFT); // move caret between the pair + linger_fired = true; + } +} + +// Smart Caps Word: letters get shifted; digits / _ / - / bspc keep it on; +// anything else ends it. Space is handled by caps_word_space_track() below +// (space -> _, double space -> real space + exit), so KC_SPC deliberately does +// NOT appear here: if a space ever reaches this hook the intercept missed it, +// and ending the word is the right fallback. +bool caps_word_press_user(uint16_t keycode) { + switch (keycode) { + case KC_A ... KC_Z: + add_weak_mods(MOD_BIT(KC_LSFT)); // shift the letter + return true; + case KC_1 ... KC_0: + case KC_BSPC: + case KC_DEL: + case KC_MINS: + case KC_UNDS: + return true; // keep Caps Word active, don't shift + default: + return false; // turn Caps Word off + } +} + +// --- Caps Word space handling (SCREAMING_SNAKE) ------------------------------ +// space -> `_`, Caps Word stays on +// space space -> deletes the `_`, sends a real space, Caps Word turns off +// +// This lives in process_record_user rather than caps_word_press_user because +// that hook can't suppress a keypress — it can only allow it or end the word. +// +// NOTE: the space key is LT(L_NAV, KC_SPC) on both bases, so this only sees +// KC_SPC because process_record_user strips mod-taps/layer-taps to their tap +// keycode first. Keep this call *after* that strip. +static bool cw_space_pending = false; // did the last press emit a `_`? + +// QMK calls this whenever Caps Word turns on or off (including idle timeout), +// so the pending-space state can never outlive the word. +void caps_word_set_user(bool active) { + cw_space_pending = false; +} + +// returns true if it consumed the key press (caller should stop processing). +bool caps_word_space_track(uint16_t keycode, keyrecord_t *record) { + if (!record->event.pressed || !is_caps_word_on()) { + return false; + } + if (keycode != KC_SPC) { + cw_space_pending = false; // any other key ends the double-space window + return false; + } + if (cw_space_pending) { // second space in a row: end the word + RAMO_TAP(KC_BSPC); // remove the `_` we just typed + caps_word_off(); // clears cw_space_pending via caps_word_set_user + RAMO_TAP(KC_SPC); // ...and leave a real space behind + return true; + } + RAMO_TAP16(KC_UNDS); // first space: SCREAMING_SNAKE separator + cw_space_pending = true; + return true; +} diff --git a/users/raphaelmor/ramo_matrix.c b/users/raphaelmor/ramo_matrix.c index 65a18ca6..e378290f 100644 --- a/users/raphaelmor/ramo_matrix.c +++ b/users/raphaelmor/ramo_matrix.c @@ -1,4 +1,14 @@ +/* + * ramo_matrix.c + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ void matrix_scan_user(void) { combo_check_for_linger(); + linger_scan(); } diff --git a/users/raphaelmor/ramo_process_record.c b/users/raphaelmor/ramo_process_record.c index 0ad834ca..81fdf108 100644 --- a/users/raphaelmor/ramo_process_record.c +++ b/users/raphaelmor/ramo_process_record.c @@ -1,10 +1,24 @@ +/* + * ramo_process_record.c + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + // This is called everytime a key is pressed or released bool process_record_user(uint16_t keycode, keyrecord_t *record) { - bool result = true; - // uint8_t saved_modifiers; - // saved_modifiers = get_mods(); // share modifiers - // Do we need to filter multi-function keys? + + + if (record->event.pressed) { + uprintf("RAMO: USER: Pressed kc: %s\n", get_keycode_string(keycode)); + } else { + uprintf("RAMO: USER: Released kc: %s\n", get_keycode_string(keycode)); + } + switch (keycode) { case QK_MOD_TAP ... QK_MOD_TAP_MAX: case QK_LAYER_TAP ... QK_LAYER_TAP_MAX: @@ -12,20 +26,66 @@ bool process_record_user(uint16_t keycode, keyrecord_t *record) { if (!record->tap.count) // if not tapped yet… return true; // let QMK do that first keycode &= QK_BASIC_MAX; // mods & taps have been handled. + uprintf("RAMO: USER: Key is complex -> Keycode &= QK_BASIC_MAX: %s\n", get_keycode_string(keycode)); + } + + // Linger brackets: track ( and { presses for hold-to-autopair (see ramo_linger.c) + if (linger_track(keycode, record)) { + return false; // consumed: open bracket already tapped + } + + // Caps Word spaces: space -> _, double space -> real space + exit. + // (see caps_word_space_track in ramo_linger.c) + if (caps_word_space_track(keycode, record)) { + return false; + } + + // Shift + Backspace = Delete. Keeps Del available on the Hands Down base, + // where it's no longer on a thumb (Option A). Only fires with shift alone. + if (record->event.pressed && keycode == KC_BSPC + && (get_mods() & MOD_MASK_SHIFT) && !(get_mods() & ~MOD_MASK_SHIFT)) { + uint8_t saved = get_mods(); + clear_mods(); + RAMO_TAP(KC_DEL); + set_mods(saved); + return false; + } + + // ADAPT_SHIFT (Hands Down base only): comma-leader one-shot Shift. + // Type `,` then a letter within RAMO_ADAPT_SHIFT_TERM → delete the comma and + // capitalize the letter (", the" -> "The"). Skipped while Caps Word is active. + // Gated to HDP because `,` without a trailing space is common in code (arg lists). + if (record->event.pressed + && get_highest_layer(default_layer_state) == L_HANDSDOWN + && prior_keycode == KC_COMM + && !is_caps_word_on() + && TIMER_DIFF_16(record->event.time, prior_keydown_timer) <= RAMO_ADAPT_SHIFT_TERM + && keycode >= KC_A && keycode <= KC_Z) { + RAMO_TAP(KC_BSPC); // remove the leader comma + RAMO_TAP16(S(keycode)); // send the capitalized letter + preprior_keycode = prior_keycode; // keep history consistent for 3-key rules + prior_keycode = keycode; // record for the next adaptive check + prior_keydown_timer = record->event.time; + return false; // handled } if (record->event.pressed) { if (!process_adaptive_key(keycode, record)) { // false means we handled the key press - prior_keycode = keycode &= QK_BASIC_MAX; // this keycode is stripped of mods+taps - prior_keydown_timer = timer_read(); // reset the adaptive timer + preprior_keycode = prior_keycode; + prior_keycode = keycode; // this keycode is stripped of mods+taps + // Store the PRESS time, not timer_read(): mod-taps arrive late (see + // the note in ramo_adaptive.c), and using read-time here would make + // the *next* key's window start from this key's release instead. + prior_keydown_timer = record->event.time; + + uprintf("RAMO: USER: Adaptive succeed. Setting prior to %s and reset timer\n", get_keycode_string(prior_keycode)); return false; // took care of that key } - // RAMO: is this needed ? - // prior_keycode = keycode &= QK_BASIC_MAX; // this keycode is stripped of mods+taps - // prior_keydown_timer = timer_read(); // reset the adaptive timer - } else { - + preprior_keycode = prior_keycode; + prior_keycode = keycode; // this keycode is stripped of mods+taps + prior_keydown_timer = record->event.time; // press time (see above) + uprintf("RAMO: USER: Adaptive failed. setting prior to %s and reset timer\n", get_keycode_string(prior_keycode)); } - return result; + return true; } diff --git a/users/raphaelmor/ramo_tap_dance.c b/users/raphaelmor/ramo_tap_dance.c index a8512806..6727604d 100644 --- a/users/raphaelmor/ramo_tap_dance.c +++ b/users/raphaelmor/ramo_tap_dance.c @@ -1,3 +1,13 @@ +/* + * ramo_tap_dance.c + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + void ramo_TD_make_colemak_default(tap_dance_state_t *state, void *user_data) { if (state->count == 2) { set_single_default_layer(L_COLEMAK); diff --git a/users/raphaelmor/ramo_tap_dance.h b/users/raphaelmor/ramo_tap_dance.h index 6d26f516..9f371e62 100644 --- a/users/raphaelmor/ramo_tap_dance.h +++ b/users/raphaelmor/ramo_tap_dance.h @@ -1,3 +1,13 @@ +/* + * ramo_tap_dance.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + // Tap Dance definitions enum { RAMO_TD_CMK, diff --git a/users/raphaelmor/raphaelmor.c b/users/raphaelmor/raphaelmor.c index 24706cda..a5270bba 100644 --- a/users/raphaelmor/raphaelmor.c +++ b/users/raphaelmor/raphaelmor.c @@ -1,3 +1,13 @@ +/* + * raphaelmor.c + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #include QMK_KEYBOARD_H #include "raphaelmor.h" @@ -11,7 +21,13 @@ uint8_t saved_modifiers; uint32_t linger_timer = 0; // time elapsed since combo was pressed // Adaptive uint16_t prior_keycode = KC_NO; +uint16_t preprior_keycode = KC_NO; // key before prior (for 3-key adaptives) uint16_t prior_keydown_timer = 0; // timer of keydown for adaptive threshhold. +// Linger brackets +uint16_t linger_key = 0; // currently-lingering open bracket (0 = none) +uint16_t linger_close = 0; // its matching close bracket +uint16_t linger_ktimer = 0; // keydown timer for the linger hold +bool linger_fired = false; // has the pair already been inserted? void keyboard_post_init_user(void) { // Customise these values to desired behaviour @@ -27,6 +43,9 @@ void keyboard_post_init_user(void) { // Include combo processing code #include "ramo_combos.c" +// Include linger-bracket + casemods code (must precede matrix + process_record) +#include "ramo_linger.c" + // Include matrix processing code #include "ramo_matrix.c" diff --git a/users/raphaelmor/raphaelmor.h b/users/raphaelmor/raphaelmor.h index bb604a60..4a7abb91 100644 --- a/users/raphaelmor/raphaelmor.h +++ b/users/raphaelmor/raphaelmor.h @@ -1,5 +1,39 @@ +/* + * raphaelmor.h + * + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2026 Raphaël Mor + * + * Part of a QMK userspace whose design follows Hands Down Promethium by moutis + * (https://github.com/moutis/HandsDown, GPL-3.0). See ./LICENSE. + */ + #pragma once #include "ramo_tap_dance.h" #include "ramo_layers.h" + +// --- Firmware-verification logging ------------------------------------------ +// Log the ACTUAL keycodes/strings sent to the host, as distinct from the +// "Pressed kc" logged in process_record_user (which is the keymap keycode — +// the intent — before combos/adaptives/linger transform it). This lets the +// touchtutor `tt verify` tool cross-check "what was pressed" vs "what was sent" +// and catch transformation bugs, including for keys that emit no character. +// +// Wrap tap_code/tap_code16/send_string at the userspace emission sites (combos, +// adaptives, linger, and the process_record transforms). Plain keys are sent by +// core QMK unchanged, so their output already equals the logged "Pressed kc". +// +// Uses uprintf (CONSOLE_ENABLE) + get_keycode_string (KEYCODE_STRING_ENABLE), +// both already enabled in rules.mk. Define RAMO_NO_SENT_LOG for a quiet build. +#ifdef RAMO_NO_SENT_LOG +# define RAMO_TAP(kc) tap_code(kc) +# define RAMO_TAP16(kc) tap_code16(kc) +# define RAMO_SEND_STRING(s) send_string(s) +#else +# define RAMO_TAP(kc) do { uprintf("RAMO: SENT: %s\n", get_keycode_string(kc)); tap_code(kc); } while (0) +# define RAMO_TAP16(kc) do { uprintf("RAMO: SENT: %s\n", get_keycode_string(kc)); tap_code16(kc); } while (0) +# define RAMO_SEND_STRING(s) do { uprintf("RAMO: SENT_STR: %s\n", s); send_string(s); } while (0) +#endif + diff --git a/users/raphaelmor/rules.mk b/users/raphaelmor/rules.mk index b0b077f0..ddb1a095 100644 --- a/users/raphaelmor/rules.mk +++ b/users/raphaelmor/rules.mk @@ -2,6 +2,10 @@ CAPS_WORD_ENABLE = yes COMBO_ENABLE = yes TAP_DANCE_ENABLE = yes +// TEMP: for debugging adaptive +KEYCODE_STRING_ENABLE = yes +CONSOLE_ENABLE = yes + INTROSPECTION_KEYMAP_C += raphaelmor.c diff --git a/users/raphaelmor/tools/kle_docs.py b/users/raphaelmor/tools/kle_docs.py new file mode 100644 index 00000000..1eba61cf --- /dev/null +++ b/users/raphaelmor/tools/kle_docs.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Raphaël Mor +"""Write both layout documents from the QMK sources. + +Two targets, one command, because they must not disagree: + + ../../../keyboard-layout/README.md the layout, no board in it, plus docs/*.svg + users/raphaelmor/README.md what is true of this Corne and this firmware + +Each file keeps everything outside + + + + +so the prose you write around the generated block survives a regeneration. + + python3 tools/kle_docs.py # write both + python3 tools/kle_docs.py --check # exit 1 and name anything stale +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import kle_export as E # noqa: E402 +import kle_render as R # noqa: E402 + +QMK_README = E.USERSPACE / "README.md" +LAYOUT_REPO = E.USERSPACE.parents[2] / "keyboard-layout" +BEGIN = "" +END = "" +GENERATED = ("") + +LAYOUT_PREAMBLE = """# Colemak + Hands Down Promethium + +A 36-key layout, on two alpha bases that share one set of layers. + +**[Hands Down Promethium](https://github.com/moutis/HandsDown)** is the base I +type on. **Colemak** is kept alongside it because it is what everything else in +the world assumes, and because switching back costs one double tap. + +The layers follow **[Miryoku](https://github.com/manna-harbour/miryoku)**: hold a +thumb, get a layer, and the payload sits on the hand opposite the thumb that +activated it. On top of that sit combos for the letters and glyphs that have no +home on 36 keys, and adaptive keys that rewrite same-finger bigrams as you roll +them. + +Nothing below names a switch. Everything is described by position, so it holds +for any 3x5 + 3 split. + + +""" + +QMK_PREAMBLE_MARKER = "# HANDS DOWN GOLD and COLEMAK MIRYOKU CUSTOM SETUP FOR CORNE" + + +def render_layers(source: dict) -> dict[str, str]: + """Layer prefix -> SVG text, one board per file.""" + return {prefix: R.render(E.one_board(source, enum, prefix)) + for enum, prefix in source["layers"]} + + +def section(body: str) -> str: + return "\n".join([BEGIN, "", GENERATED, "", body, "", END]) + + +def splice(existing: str, body: str, fallback_header: str = "") -> str: + """Replace the marked region, or create the file around it.""" + start, end = existing.find(BEGIN), existing.find(END) + if start == -1 or end == -1: + head = existing.rstrip() or fallback_header.rstrip() + return head + "\n\n" + section(body) + "\n" + return existing[:start] + section(body) + existing[end + len(END):] + + +def read_or_empty(path: Path) -> str: + return path.read_text() if path.exists() else "" + + +def planned_files(source: dict, layout_repo: Path | None) -> dict[Path, str]: + """Every path this run would write, and what it would put there.""" + files: dict[Path, str] = { + QMK_README: splice(read_or_empty(QMK_README), + E.qmk_doc(source), + QMK_PREAMBLE_MARKER), + } + if layout_repo is None: + return files + + body = E.layout_doc(source, "docs/layer-{prefix}.svg") + files[layout_repo / "README.md"] = splice( + read_or_empty(layout_repo / "README.md"), body, LAYOUT_PREAMBLE) + for prefix, svg in render_layers(source).items(): + files[layout_repo / "docs" / f"layer-{prefix.lower()}.svg"] = svg + return files + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--layout-repo", type=Path, default=LAYOUT_REPO, + help=f"default {LAYOUT_REPO}") + ap.add_argument("--check", action="store_true", + help="report what is stale and change nothing") + ap.add_argument("--gold", action="store_true", + help="render Hands Down Gold instead of Promethium") + args = ap.parse_args() + + repo = args.layout_repo if args.layout_repo.is_dir() else None + if repo is None: + print(f"kle_docs: {args.layout_repo} is not there — writing the QMK " + f"README only", file=sys.stderr) + + try: + source = E.read_all(promethium=not args.gold) + files = planned_files(source, repo) + except E.ExportError as err: + print(f"kle_docs: {err}", file=sys.stderr) + return 1 + + stale = [p for p, text in files.items() + if not p.exists() or p.read_text() != text] + if args.check: + for path in stale: + print(f"out of date: {path}") + print("up to date" if not stale + else f"{len(stale)} file(s) need `python3 tools/kle_docs.py`") + return 1 if stale else 0 + + for path, text in files.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + print(f"wrote {len(files)} file(s), {len(stale)} changed") + for path in stale: + print(f" {path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/users/raphaelmor/tools/kle_export.py b/users/raphaelmor/tools/kle_export.py new file mode 100644 index 00000000..bb4a2da1 --- /dev/null +++ b/users/raphaelmor/tools/kle_export.py @@ -0,0 +1,1073 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Raphaël Mor +"""Render the QMK keymap in users/raphaelmor as a keyboard-layout-editor document. + +The keymap is built out of X-macros, so the only honest way to read it is to run +the C preprocessor over it exactly as the firmware build does. This tool does +that, then lays the result out as one KLE document: a board per layer, plus +boards for the combos, the adaptive rules, and the always-on behaviors. + + python3 tools/kle_export.py # writes corne-v4-1-ramo.json + python3 tools/kle_export.py -o /tmp/x.json # somewhere else + python3 tools/kle_export.py --gold # render Hands Down Gold instead + +Paste the output into keyboard-layout-editor.com under Raw data. + +There are no unit tests. Instead every structural assumption is a check() call +that aborts the export, so a wrong file never reaches disk. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import kle_legends as L # noqa: E402 + +USERSPACE = Path(__file__).resolve().parent.parent +KEYMAP_C = (USERSPACE.parent.parent / "keyboards/crkbd/rev4_1/standard" + / "keymaps/raphaelmor/keymap.c") + + +class ExportError(Exception): + """A structural assumption about the C sources no longer holds.""" + + +def check(condition: bool, message: str) -> None: + if not condition: + raise ExportError(message) + + +# --- Geometry ---------------------------------------------------------------- +# Positions are named by the diagram every layer header carries: +# +# ╭───────────────────────────────╮ ╭───────────────────────────────╮ +# │ LT5 LT4 LT3 LT2 LT1 LT0 LTA │ │ RTA RT0 RT1 RT2 RT3 RT4 RT5 │ +# │ LM5 LM4 LM3 LM2 LM1 LM0 LMA │ │ RMA RM0 RM1 RM2 RM3 RM4 RM5 │ +# │ LB5 LB4 LB3 LB2 LB1 LB0 ╭─────╯ ╰─────╮ RB0 RB1 RB2 RB3 RB4 RB5 │ +# ╰───────────╮ LH2 LH1 LH0 │ │ RH0 RH1 RH2 ╭───────────╯ +# ╰─────────────╯ ╰─────────────╯ + +ROWS = ["T", "M", "B"] +LEFT_COLS = ["5", "4", "3", "2", "1", "0"] +RIGHT_COLS = ["0", "1", "2", "3", "4", "5"] + +# The board has 46 switches, but 10 of them carry nothing on any layer: the outer +# pinky column (LT5 LM5 LB5 RT5 RM5 RB5) and the four ex2 inner keys (LTA LMA RTA +# RMA). They are not drawn. What is left is a plain 3x5 + 3 per half. +SKIP_COLS = {"5"} +SKIP_POSITIONS = {"LTA", "LMA", "RTA", "RMA"} +DRAWN_LEFT_COLS = [c for c in LEFT_COLS if c not in SKIP_COLS] +DRAWN_RIGHT_COLS = [c for c in RIGHT_COLS if c not in SKIP_COLS] + +# Column stagger, decoded from the hand-made corne-v4-1-miryoku-adaptation.json. +# Index matches DRAWN_LEFT_COLS: ring first, index inner last. +COL_Y = [0.3, 0.1, 0.0, 0.1, 0.2] +ROW_PITCH = 1.0 +HALF_GAP = 3.0 # blank units between the two halves +RIGHT_X0 = len(DRAWN_LEFT_COLS) + HALF_GAP +THUMB_DX = 0.5 # thumbs sit half a unit inboard of the columns above them +THUMB_Y = 3.35 +BOARD_PITCH = 6.6 # vertical distance between two stacked boards +TITLE_Y = 2.6 # the wide title decal sits in the gap, bottom-row height + + +def build_geometry() -> dict[str, tuple[float, float]]: + """Position name -> (x, y) in key units, relative to the board origin.""" + geo: dict[str, tuple[float, float]] = {} + for r_i, row in enumerate(ROWS): + for c_i, col in enumerate(DRAWN_LEFT_COLS): + geo[f"L{row}{col}"] = (float(c_i), COL_Y[c_i] + r_i * ROW_PITCH) + for c_i, col in enumerate(DRAWN_RIGHT_COLS): + # The right half mirrors the left, so its stagger reads backwards. + geo[f"R{row}{col}"] = (RIGHT_X0 + c_i, COL_Y[-1 - c_i] + r_i * ROW_PITCH) + left_thumb0 = len(DRAWN_LEFT_COLS) - 3 + THUMB_DX + for i, name in enumerate(("LH2", "LH1", "LH0")): + geo[name] = (left_thumb0 + i, THUMB_Y) + for i, name in enumerate(("RH0", "RH1", "RH2")): + geo[name] = (RIGHT_X0 - THUMB_DX + i, THUMB_Y) + return geo + + +GEOMETRY = build_geometry() + +# The order the LAYOUT macro takes its arguments in. Checked against keymap.c. +POSITIONS = ( + [f"LT{c}" for c in LEFT_COLS] + ["LTA", "RTA"] + [f"RT{c}" for c in RIGHT_COLS] + + [f"LM{c}" for c in LEFT_COLS] + ["LMA", "RMA"] + [f"RM{c}" for c in RIGHT_COLS] + + [f"LB{c}" for c in LEFT_COLS] + [f"RB{c}" for c in RIGHT_COLS] + + ["LH2", "LH1", "LH0", "RH0", "RH1", "RH2"] +) + +# The subset that gets drawn. +DRAWN = [p for p in POSITIONS + if p not in SKIP_POSITIONS and p[-1] not in SKIP_COLS] + + +def check_positions_match_keymap() -> None: + """The LAYOUT call in keymap.c is the real argument order. Follow it.""" + if not KEYMAP_C.exists(): # a bare checkout of just the userspace + return + source = KEYMAP_C.read_text() + body = source.split("LAYOUT_split_3x6_3_ex2(", 1) + check(len(body) > 1, f"no LAYOUT_split_3x6_3_ex2 call in {KEYMAP_C}") + found = re.findall(r"STR##_([LR][TMBH][0-5A])\b", body[1]) + check(found == POSITIONS, + "POSITIONS no longer matches the LAYOUT call in keymap.c.\n" + f" keymap.c: {found}\n here: {POSITIONS}") + + +# --- Preprocessor ------------------------------------------------------------ + +def expand(lines: list[str], *, promethium: bool, preamble: str = "") -> str: + """Run the C preprocessor over `lines` with the layer headers in scope.""" + document = '#include "ramo_layers.h"\n' + preamble + "\n".join(lines) + "\n" + cmd = ["clang", "-E", "-P", "-x", "c", "-I", str(USERSPACE)] + if promethium: + cmd.append("-DRAMO_USE_HD_PROMETHIUM") + cmd.append("-") + done = subprocess.run(cmd, input=document, capture_output=True, text=True) + check(done.returncode == 0, + f"the preprocessor failed:\n{done.stderr.strip()}") + return done.stdout + + +def read_layers(promethium: bool) -> list[tuple[str, str]]: + """[(enum name, prefix)], in layer-index order, straight from ramo_layers.h. + + ramo_layers.h undefines RAMO_DO after building the enum, so expanding + RAMO_FOR_EACH_LAYER on its own yields literal RAMO_DO(NAME, PREFIX) calls. + """ + out = expand(["RAMO_FOR_EACH_LAYER"], promethium=promethium) + layers = re.findall(r"RAMO_DO\(\s*(\w+)\s*,\s*(\w+)\s*\)", out) + check(len(layers) >= 2, f"could not read the layer list, got: {out!r}") + return layers + + +def read_keys(layers, promethium: bool) -> dict[str, dict[str, str]]: + """Keycode per layer prefix and position.""" + lines = [] + for _, prefix in layers: + for pos in POSITIONS: + lines.append(f"KEY({prefix},{pos},{prefix}_{pos})") + out = expand(lines, promethium=promethium) + + keys: dict[str, dict[str, str]] = {p: {} for _, p in layers} + for prefix, pos, value in re.findall( + r"\bKEY\((\w+),(\w+),(.*?)\)\s*$", out, re.MULTILINE): + keys[prefix][pos] = value.strip() + + for _, prefix in layers: + check(len(keys[prefix]) == len(POSITIONS), + f"{prefix}: expanded {len(keys[prefix])} keys, want {len(POSITIONS)}") + return keys + + +def read_combo_gates(source: str, promethium: bool) -> dict[str, str]: + """Combo enum name -> the base layer it is limited to, from combo_should_trigger. + + The gate is written as a range (`case HD_COMBO_ACUT ... HD_COMBO_CAPW:`), so + the enum order decides which combos it covers. The enum has an #ifdef in it, + so let the preprocessor resolve it rather than guessing. + """ + block = re.search(r"enum ramo_combos \{(.*?)\n\};", source, re.S) + check(block is not None, "no `enum ramo_combos` found in ramo_combos.c") + expanded = expand(["enum ramo_combos {" + block.group(1) + "\n};"], + promethium=promethium) + tail = expanded.split("enum ramo_combos {", 1)[1].split("};", 1)[0] + order = [n for n in re.findall(r"\b(\w+)\b(?:\s*=\s*\d+)?\s*,", tail) + if not n.isdigit()] + check("NO_COMBO" in order, f"could not read the combo enum, got {order}") + + gates: dict[str, str] = {} + for first, last, layer in re.findall( + r"case (\w+)\s*\.\.\.\s*(\w+):\s*\n\s*return get_highest_layer\(" + r"default_layer_state\) == (L_\w+);", source): + check(first in order and last in order, + f"gate range {first}..{last} names a combo that is not in the enum") + lo, hi = order.index(first), order.index(last) + check(lo <= hi, f"gate range {first}..{last} runs backwards") + for name in order[lo:hi + 1]: + gates[name] = layer + return gates + + +def read_combos(layers, promethium: bool) -> list[dict]: + """Combos from ramo_combos.c, with each member resolved to a key position. + + Position macros are redefined to markers before the combo arrays expand, so an + alias like HD_Q_keys resolves to @@LT3@@, @@LT1@@ instead of to keycodes. + """ + source = (USERSPACE / "ramo_combos.c").read_text() + gates = read_combo_gates(source, promethium) + + arrays = dict(re.findall( + r"const uint16_t PROGMEM (\w+)\[\]\s*=\s*\{(.*?),\s*COMBO_END\s*\}", + source, re.S)) + check(arrays, "no combo key arrays found in ramo_combos.c") + + # Redefine every position macro to a marker that survives expansion. + redef = [] + for _, prefix in layers: + for pos in POSITIONS: + redef.append(f"#undef {prefix}_{pos}") + redef.append(f"#define {prefix}_{pos} @@{prefix}_{pos}@@") + preamble = "\n".join(redef) + "\n" + + names = list(arrays) + out = expand([f"MEMBERS({n},{arrays[n]})" for n in names], + promethium=promethium, preamble=preamble) + members: dict[str, list[str]] = {} + raw: dict[str, str] = {} + for name, body in re.findall(r"MEMBERS\((\w+),(.*?)\)\s*$", out, re.MULTILINE): + members[name] = re.findall(r"@@(\w+)_([LR][TMBH][0-5A])@@", body) + raw[name] = body.strip() + check(set(members) == set(names), + f"combo members did not resolve: {set(names) - set(members)}") + + # key_combos[] gives the enum name, the array, and the output. + combos = [] + for enum, kind, array, arg in re.findall( + r"\[(\w+)\]\s*=\s*(COMBO_ACTION|COMBO)\(\s*(\w+)\s*(?:,\s*([^)]*?))?\s*\)", + source): + check(array in members, f"combo {enum} uses unknown array {array}") + resolved = members[array] + check(len(resolved) >= 2, + f"combo {enum}: {array} resolved to {len(resolved)} key positions, " + f"want 2 or more. The preprocessor made it {raw[array]!r}, so it " + f"names a position macro that this build does not define.") + prefixes = {p for p, _ in resolved} + check(len(prefixes) == 1, + f"combo {enum} mixes layers: {prefixes}") + combos.append({ + "enum": enum, + "prefix": prefixes.pop(), + "positions": [pos for _, pos in resolved], + "output": (L.COMBO_ACTION_NOTES.get(enum) if kind == "COMBO_ACTION" + else L.COMBO_RESULT_NOTES.get(enum) or legend_text(arg)), + "action": kind == "COMBO_ACTION", + "gate": gates.get(enum), + }) + check(combos[-1]["output"] is not None, + f"combo {enum} is a COMBO_ACTION with no note in kle_legends.py") + + declared = len(re.findall(r"^\s*\[(\w+)\]\s*=\s*COMBO", source, re.MULTILINE)) + check(len(combos) == declared, + f"parsed {len(combos)} combos but key_combos[] declares {declared}") + return combos + + +def read_adaptives() -> list[dict]: + """Adaptive rules from ramo_adaptive.c. + + The nested switch gives the trigger key and the prior key. The effect comes + from the trailing // comment every rule already carries, which is both exact + and already maintained. A comment describes a rule when it contains "->"; + the other comments in the file explain a step or record a removal. + + One inner case can hold two rules: a three-key rule inside an + `if (preprior_keycode == …)` guard, then the two-key rule after it. + """ + source = (USERSPACE / "ramo_adaptive.c").read_text() + body = source.split("switch (keycode) {", 1) + check(len(body) > 1, "no outer switch found in ramo_adaptive.c") + body = body[1].split("\n }\n", 1)[0] + + rules: list[dict] = [] + described = 0 + trigger = prior = None + for line in body.splitlines(): + outer = re.match(r"\s{8}case (KC_\w+):", line) + if outer: + trigger, prior = outer.group(1), None + continue + inner = re.match(r"\s{16}case (KC_\w+):(.*)", line) + if inner: + prior = inner.group(1) + line = inner.group(2) + comment = re.search(r"//\s*(.+?)\s*$", line) + if not comment or "->" not in comment.group(1): + continue + described += 1 + check(trigger is not None and prior is not None, + f"rule comment outside any case: {comment.group(1)!r}") + guard = re.search(r"preprior_keycode == (KC_\w+)", line) + rules.append({"trigger": trigger, "prior": prior, + "note": comment.group(1), + "preprior": guard.group(1) if guard else None}) + + # A fallthrough case (`case KC_G:` with the body on the next `case`) carries + # its own comment, so every "->" comment must have produced exactly one rule. + check(len(rules) == described, + f"parsed {len(rules)} rules from {described} rule comments") + check(rules, "no adaptive rules parsed from ramo_adaptive.c") + return rules + + +# --- Keycode to legend ------------------------------------------------------- + +def split_args(text: str) -> list[str]: + """Split a macro argument list on commas that are not inside parentheses.""" + args, depth, current = [], 0, "" + for ch in text: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if ch == "," and depth == 0: + args.append(current.strip()) + current = "" + else: + current += ch + args.append(current.strip()) + return args + + +def parse_keycode(keycode: str) -> tuple[object, str | None]: + """(tap legend, hold legend). The hold legend goes on the front face.""" + keycode = keycode.strip() + if keycode in L.BASIC: + return L.BASIC[keycode], None + + call = re.fullmatch(r"(\w+)\((.*)\)", keycode, re.S) + if not call: + raise ExportError(f"no legend for keycode {keycode!r} — add it to kle_legends.py") + name, args = call.group(1), split_args(call.group(2)) + + if name in L.MOD_TAPS: + tap, _ = parse_keycode(args[0]) + return tap, L.MOD_TAPS[name] + if name == "LT": + tap, _ = parse_keycode(args[1]) + layer = L.LAYER_NAMES.get(args[0]) + check(layer is not None, f"unknown layer {args[0]!r} in {keycode!r}") + return tap, layer + if name == "DF": + layer = L.LAYER_NAMES.get(args[0]) + check(layer is not None, f"unknown layer {args[0]!r} in {keycode!r}") + return f"Base: {layer}", None + if name == "TD": + note = L.TAP_DANCES.get(args[0]) + check(note is not None, f"unknown tap dance {args[0]!r} — add it to kle_legends.py") + return note, None + if name in L.CHORDS: + inner, _ = parse_keycode(args[0]) + if isinstance(inner, tuple): + inner = inner[1] + return L.CHORDS[name] + inner, None + + raise ExportError(f"no legend for keycode {keycode!r} — add it to kle_legends.py") + + +def legend_text(keycode: str) -> str: + """One flat string for a keycode. Used where a key has no room for two lines.""" + tap, hold = parse_keycode(keycode) + if isinstance(tap, tuple): + tap = tap[1] + return f"{tap} ({hold})" if hold else tap + + +# --- KLE serialization ------------------------------------------------------- +# KLE stores a label list of 12 slots whose meaning depends on the key's align +# flag. labelMap[align][slot] is the visual position that slot renders at: +# +# 0 1 2 top row of the keycap +# 3 4 5 middle +# 6 7 8 bottom +# 9 10 11 the sloped front face +# +# Taken from the keyboard-layout-editor deserializer. + +LABEL_MAP = [ + [0, 6, 2, 8, 9, 11, 3, 5, 1, 4, 7, 10], # 0 = nothing centred + [1, 7, -1, -1, 9, 11, 4, -1, -1, -1, -1, 10], + [3, -1, 5, -1, 9, 11, -1, -1, 4, -1, -1, 10], + [4, -1, -1, -1, 9, 11, -1, -1, -1, -1, -1, 10], + [0, 6, 2, 8, 10, -1, 3, 5, 1, 4, 7, -1], + [1, 7, -1, -1, 10, -1, 4, -1, -1, -1, -1, -1], # 5 = centre x + front + [3, -1, 5, -1, 10, -1, -1, -1, 4, -1, -1, -1], + [4, -1, -1, -1, 10, -1, -1, -1, -1, -1, -1, -1], # 7 = centre x, y + front +] + +TOP, MIDDLE, BOTTOM, FRONT = 1, 4, 7, 10 +TOP_LEFT, CENTER = 0, 4 +# A 1u cap has a 3x3 label grid on its top face plus a 3-slot front face. These +# are the stacks that do not collide: one centred column of four, and two columns +# of three for short strings. +CENTER_STACK = (1, 4, 7, 10) +TWO_COLUMNS = (0, 2, 3, 5, 6, 8) +FRONT_CENTER = 10 + + +def labels(align: int, placed: dict[int, str]) -> str: + """Build the \\n-joined label string putting each text at a visual position.""" + slot_of = {} + for slot, visual in enumerate(LABEL_MAP[align]): + if visual >= 0 and visual not in slot_of: + slot_of[visual] = slot + out = [""] * 12 + for visual, text in placed.items(): + check(visual in slot_of, + f"visual position {visual} is unreachable with align {align}") + out[slot_of[visual]] = text + while out and not out[-1]: + out.pop() + return "\n".join(out) + + +class Sheet: + """Collects absolutely-positioned keys and serializes them as KLE rows.""" + + def __init__(self) -> None: + self.keys: list[dict] = [] + + def add(self, x, y, text, *, color, text_color, width=1.0, align=7, + font=3, decal=False, ghost=False) -> None: + self.keys.append(dict(x=x, y=y, text=text, color=color, tcolor=text_color, + width=width, align=align, font=font, + decal=decal, ghost=ghost)) + + def rows(self) -> list: + """KLE rows. Keys are grouped by y; x and y are emitted as deltas. + + KLE keeps colour, text colour, align, font and ghost until they change, + but resets width and decal to their defaults after every key. So the + first group is emitted on change and the second on every key that needs it. + """ + sticky = (("c", "color"), ("t", "tcolor"), ("a", "align"), + ("f", "font"), ("g", "ghost")) + per_key = (("w", "width", 1.0), ("d", "decal", False)) + out = [] + # Start every sticky property as unknown, not as its default. KLE carries + # them across rows, and these rows get concatenated after another sheet's, + # so the first key must state all of them. + state = dict(color=None, tcolor=None, align=None, font=None, ghost=None) + y_cursor = 0.0 + for y in sorted({round(k["y"], 4) for k in self.keys}): + row, x_cursor = [], 0.0 + pending = {} + if abs(y - y_cursor) > 1e-9: + pending["y"] = round(y - y_cursor, 4) + for key in sorted((k for k in self.keys if round(k["y"], 4) == y), + key=lambda k: k["x"]): + if abs(key["x"] - x_cursor) > 1e-9: + pending["x"] = round(key["x"] - x_cursor, 4) + for prop, field in sticky: + if key[field] != state[field]: + pending[prop] = key[field] + state[field] = key[field] + for prop, field, default in per_key: + if key[field] != default: + pending[prop] = key[field] + if pending: + row.append(pending) + pending = {} + row.append(key["text"]) + x_cursor = key["x"] + key["width"] + out.append(row) + y_cursor = y + 1.0 + return out + + +def deserialize(rows: list) -> list[tuple[float, float, float]]: + """Minimal KLE reader, used to check that Sheet.rows() round-trips. + + Mirrors KLE: x and y accumulate, and width resets to 1 after every key. + """ + found, y, width = [], 0.0, 1.0 + for row in rows: + if isinstance(row, dict): + continue + x = 0.0 + for item in row: + if isinstance(item, dict): + x += item.get("x", 0) + y += item.get("y", 0) + width = item.get("w", 1.0) + else: + found.append((round(x, 4), round(y, 4), round(width, 4))) + x += width + width = 1.0 + y += 1.0 + return found + + +# --- Boards ------------------------------------------------------------------ + +def title(sheet: Sheet, y0: float, text: str, sub: str = "") -> None: + sheet.add(len(DRAWN_LEFT_COLS), y0 + TITLE_Y, + labels(7, {CENTER: text, FRONT: sub} if sub else {CENTER: text}), + color=L.TITLE_COLOR, text_color=L.TITLE_TEXT, width=HALF_GAP, + align=7, decal=True) + + +def display_name(layer_enum: str) -> str: + name = L.LAYER_NAMES.get("L_" + layer_enum) + check(name is not None, + f"no display name for layer {layer_enum} — add it to kle_legends.py") + return name + + +def read_activators(layers, keys: dict) -> dict[str, dict[str, list[str]]]: + """Layer enum -> {position: the tap legend that position has on each base}. + + A layer-tap on a base layer is how every layer here is reached, so each + layer's own board can mark the key you hold to get there. + """ + found: dict[str, dict[str, list[str]]] = {} + for _, prefix in layers[:2]: + for pos in DRAWN: + call = re.fullmatch(r"LT\(\s*(L_\w+)\s*,(.*)\)", + keys[prefix][pos].strip(), re.S) + if not call: + continue + layer = call.group(1).removeprefix("L_") + tap, _ = parse_keycode(call.group(2).strip()) + if isinstance(tap, tuple): + tap = tap[1] + found.setdefault(layer, {}).setdefault(pos, []).append(tap) + return found + + +def layer_board(sheet: Sheet, y0: float, keys: dict, activators: set[str]) -> None: + for pos in DRAWN: + x, y = GEOMETRY[pos] + tap, hold = parse_keycode(keys[pos]) + if isinstance(tap, tuple): + placed = {TOP: tap[0], BOTTOM: tap[1]} + align = 5 + else: + placed = {CENTER: tap} + align = 7 + if hold: + placed[FRONT] = hold + reaches_here = pos in activators + sheet.add(x, y0 + y, labels(align, placed), + color=L.ACTIVATOR_COLOR if reaches_here else L.KEY_COLOR, + text_color=L.KEY_TEXT, align=align, + ghost=reaches_here or L.is_unbound(keys[pos])) + + +def alpha_of(keycode: str) -> str: + """The bare tap legend, for the combo and adaptive boards.""" + tap, _ = parse_keycode(keycode) + return tap[1] if isinstance(tap, tuple) else tap + + +def combos_on(combos: list[dict], layer_enum: str) -> list[dict]: + """The combos that fire while `layer_enum` is the default layer. + + A combo is a set of key positions, not of letters, so an ungated combo fires + on both bases — at whatever letters that base puts under those positions. + Two entries on the same positions with the same output are one chord to the + typist (HD_COMBO_Th and CMK_COMBO_Th are literally the same two keys), so + they collapse. + """ + seen, out = set(), [] + for combo in combos: + if combo["gate"] not in (None, "L_" + layer_enum): + continue + signature = (tuple(sorted(combo["positions"])), combo["output"]) + if signature in seen: + continue + seen.add(signature) + out.append(combo) + return out + + +def read_terms() -> dict[str, int]: + """The timing constants, from config.h. They appear in the notes.""" + text = (USERSPACE / "config.h").read_text() + out = {} + for name in ("TAPPING_TERM", "RAMO_ADAPTIVE_TERM"): + found = re.findall(rf"^#define {name}\s+(\d+)", text, re.M) + check(found, f"could not read {name} from config.h") + out[name] = int(found[-1]) # the last definition is the live one + return out + + +# --- Documentation sections -------------------------------------------------- +# Each returns a list of lines. Three composers below pick the ones their target +# needs, so the KLE Notes field, the layout repo and this userspace README all +# come from one description and cannot disagree. + +def table(head: list[str], rows: list[list[str]]) -> list[str]: + """A fixed-width table inside a code fence. Columns sized from the content.""" + width = [max(len(h), *(len(r[i]) for r in rows)) if rows else len(h) + for i, h in enumerate(head)] + + def line(values: list[str]) -> str: + return " " + " ".join(v.ljust(w) for v, w in zip(values, width)).rstrip() + + return ["```", line(head), + " " + "-" * (sum(width) + 2 * (len(width) - 1))] + \ + [line(r) for r in rows] + ["```"] + + +def section_grid() -> list[str]: + """The position names. This is the vocabulary both implementations share.""" + return [ + "## Key positions", + "", + "Every layer below is described by position, never by what a particular", + "board calls a switch. Both implementations use these same names —", + "`users/raphaelmor/layers/*-defs.h` in QMK, `config/keynames_totem.h` in ZMK.", + "", + "```", + " ╭─────────────────────────╮ ╭─────────────────────────╮", + " │ LT4 LT3 LT2 LT1 LT0 │ │ RT0 RT1 RT2 RT3 RT4 │", + " │ LM4 LM3 LM2 LM1 LM0 │ │ RM0 RM1 RM2 RM3 RM4 │", + " │ LB4 LB3 LB2 LB1 LB0 │ │ RB0 RB1 RB2 RB3 RB4 │", + " ╰───────────╮ LH2 LH1 LH0 │ │ RH0 RH1 RH2 ╭───────────╯", + " ╰─────────────╯ ╰─────────────╯", + "```", + "", + "T/M/B are the top, middle and bottom rows; H is the thumb cluster. The", + "digit counts inward from the pinky, so 0 is the index column.", + ] + + +def section_reaching(layers, activators) -> list[str]: + bases = layers[:2] + rows = [] + for enum, _ in layers: + if enum not in activators: + continue + # activators[enum][pos] lists the tap legend per base, in base order. + taps = [" / ".join(dict.fromkeys(activators[enum][pos])) + for pos in sorted(activators[enum])] + rows.append([display_name(enum), ", ".join(taps), + " ".join(sorted(activators[enum]))]) + + out = [ + "## Reaching a layer", + "", + "Each layer's picture marks the key you **hold** to get there in red, and", + "ghosts it, because it is not something that layer binds. Where the two", + f"bases differ the column reads {display_name(bases[0][0])} / " + f"{display_name(bases[1][0])}.", + "", + ] + table(["layer", "hold", "position"], rows) + + unreachable = [display_name(e) for e, _ in layers[2:] if e not in activators] + if unreachable: + out += ["", + "No layer-tap reaches " + ", ".join(unreachable) + + " — check how you get there."] + return out + + +def section_combos(layers, keys, combos) -> list[str]: + bases = layers[:2] + # One row per distinct chord. key_combos[] can register the same two + # positions twice — HD_COMBO_Th and CMK_COMBO_Th are literally the same + # chord — and combos_on() already collapses those, so count what is shown. + both = [c for c in combos + if all(c in combos_on(combos, enum) for enum, _ in bases)] + per_base: list[tuple[str, list[dict]]] = [] + shown = list(both) + for enum, _ in bases: + rest = [c for c in combos_on(combos, enum) + if id(c) not in {id(b) for b in both}] + if rest: + per_base.append((enum, rest)) + shown += rest + + def row(combo: dict) -> list[str]: + cells = ["+".join(alpha_of(keys[prefix][p]) for p in combo["positions"]) + if combo in combos_on(combos, enum) else "" + for enum, prefix in bases] + return cells + [L.COMBO_LONG_NOTES.get(combo["enum"], combo["output"]), + "+".join(combo["positions"])] + + head = [display_name(e) for e, _ in bases] + ["sends", "positions"] + rendered = table(head, [row(c) for c in shown]) + # table() emits one block; re-open it to head the per-base groups. + body = rendered[:-1][:3 + len(both)] + index = len(both) + for enum, rest in per_base: + body += ["", f" {display_name(enum)} base only"] + body += rendered[3 + index:3 + index + len(rest)] + index += len(rest) + body.append("```") + + out = [ + f"## Combos — {len(shown)}", + "", + "A combo is a set of key *positions*, not of letters. Most are limited to", + f"the {display_name(bases[1][0])} base, but {len(both)} are not, so those " + f"also fire on", + f"{display_name(bases[0][0])} — at whatever letters sit under those positions.", + "", + ] + body + + duplicates = len(combos) - len(shown) + if duplicates: + out += ["", + f"`key_combos[]` declares {len(combos)} entries. " + + (f"{duplicates} of them repeat a chord" + if duplicates > 1 else "One of them repeats a chord"), + "already listed above — same positions, same output."] + return out + + +def section_adaptives(layers, adaptives, terms) -> list[str]: + out = [ + f"## Adaptive keys — {len(adaptives)}", + "", + f"Only on the {display_name(layers[1][0])} base. Roll two keys inside " + f"{terms['RAMO_ADAPTIVE_TERM']} ms and the", + "second one rewrites the pair. Rules that name three letters need the", + "third-from-last key too.", + "", + "```", + ] + for rule in adaptives: + after = (f"{rule['preprior'][3:]}+{rule['prior'][3:]}" if rule["preprior"] + else rule["prior"][3:]) + out.append(f" {rule['note']:<52} (fires on {rule['trigger'][3:]}" + f" after {after})") + out.append("```") + return out + + +def anchor(filename: str, needle: str) -> str: + """`file:line` for the line that holds `needle`. Never a stale number.""" + path = USERSPACE / filename + check(path.exists(), f"{filename} is gone — fix the anchor in kle_legends.py") + for number, line in enumerate(path.read_text().splitlines(), 1): + if needle in line: + return f"{filename}:{number}" + raise ExportError( + f"{filename} no longer contains {needle!r} — the behaviour moved or was " + f"renamed. Fix the anchor in kle_legends.py.") + + +def section_always_on(terms) -> list[str]: + return ["## Always on", ""] + [ + f"- **{name}** — {body.format(**terms)} `{anchor(filename, needle)}`" + for name, body, filename, needle in L.ALWAYS_ON] + + +def section_not_drawn(hidden) -> list[str]: + out = [ + "## Not drawn", + "", + "The board has 46 switches. These " + f"{len(SKIP_POSITIONS) + len(SKIP_COLS) * len(ROWS) * 2} are left off:", + "", + "- Outer pinky column `LT5 LM5 LB5 RT5 RM5 RB5` — `KC_NO` on every layer.", + "- Inner ex2 keys `LTA LMA RTA RMA`.", + ] + if hidden: + out += ["", "Careful: these hidden positions do have something bound.", ""] + out += [f"- {h}" for h in hidden] + return out + + +def build_notes(layers, keys: dict, combos: list[dict], adaptives: list[dict], + activators: dict[str, dict[str, list[str]]], + hidden: list[str]) -> str: + """The keyboard's Notes field in the KLE document. + + Combos and adaptives used to sit on the caps. They never fit: a key can take + four short lines at best, and the text has to be cut down until it stops + meaning anything. Notes has no such limit and KLE shows it under the board. + """ + terms = read_terms() + out = [f"# {display_name(layers[0][0])} + {display_name(layers[1][0])} " + "· Corne v4.1", + "", + "Generated by `users/raphaelmor/tools/kle_export.py` from the QMK sources.", + "Do not hand-edit — regenerate instead.", + ""] + for block in (section_reaching(layers, activators), + section_combos(layers, keys, combos), + section_adaptives(layers, adaptives, terms), + section_always_on(terms), + section_not_drawn(hidden)): + out += block + [""] + return "\n".join(out[:-1]) + + +# --- Export ------------------------------------------------------------------ + +def round_trip(sheet: Sheet, rows: list, what: str) -> None: + """The x/y/width deltas are the easiest thing to get wrong. Read them back.""" + want = [(round(k["x"], 4), round(k["y"], 4), round(k["width"], 4)) + for k in sorted(sheet.keys, key=lambda k: (round(k["y"], 4), k["x"]))] + got = deserialize(rows) + check(got == want, + f"the {what} rows do not round-trip. First difference: " + + next((f"want {w}, got {g}" for w, g in zip(want, got) if w != g), + f"{len(want)} keys in, {len(got)} keys out")) + + +def read_all(promethium: bool) -> dict: + """Everything the C sources have to say. Shared by every output format.""" + check_positions_match_keymap() + layers = read_layers(promethium) + keys = read_keys(layers, promethium) + return { + "layers": layers, + "keys": keys, + "combos": read_combos(layers, promethium), + "adaptives": read_adaptives(), + "activators": read_activators(layers, keys), + # Nothing gets dropped quietly. If a position we do not draw has + # something bound on some layer, say so in the notes. + "hidden": [f"`{prefix}_{pos}` is `{keys[prefix][pos]}`" + for _, prefix in layers for pos in POSITIONS + if pos not in DRAWN and not L.is_unbound(keys[prefix][pos])], + "promethium": promethium, + } + + +def section_alphas(layers, keys) -> list[str]: + """The two alpha bases as text grids, so the doc is useful without images.""" + out = ["## The two bases", "", + "Switch between them with a double tap on the Nav layer. Everything", + "else — layers, combos, adaptives — is shared.", ""] + for enum, prefix in layers[:2]: + cells = {p: alpha_of(keys[prefix][p]) for p in DRAWN} + pad = max(len(v) for v in cells.values()) + half = (pad + 1) * len(DRAWN_LEFT_COLS) + 1 # a half's inner width + thumb = (pad + 1) * 3 + 1 # the thumb block's width + shoulder = half - thumb # what the thumbs sit under + + def cell_row(side: str, row: str, cols: list[str]) -> str: + return " ".join(cells[f"{side}{row}{c}"].center(pad) for c in cols) + + out += [f"### {display_name(enum)}", "", "```", + " ╭" + "─" * half + "╮ ╭" + "─" * half + "╮"] + for row in ROWS: + out.append(f" │ {cell_row('L', row, DRAWN_LEFT_COLS)} │" + f" │ {cell_row('R', row, DRAWN_RIGHT_COLS)} │") + thumbs_l = " ".join(cells[p].center(pad) for p in ("LH2", "LH1", "LH0")) + thumbs_r = " ".join(cells[p].center(pad) for p in ("RH0", "RH1", "RH2")) + out += [" ╰" + "─" * shoulder + "╮ " + thumbs_l + " │" + " │ " + thumbs_r + " ╭" + "─" * shoulder + "╯", + " " + " " * (shoulder + 1) + "╰" + "─" * thumb + "╯" + " ╰" + "─" * thumb + "╯", + "```", ""] + return out[:-1] + + +def section_board() -> list[str]: + return [ + "## The board", + "", + f"Corne v4.1, `LAYOUT_split_3x6_3_ex2` (`keymap.c`): {len(POSITIONS)} " + "switches — 3x6 per", + "half, three thumbs, plus the four ex2 inner keys `LTA LMA RTA RMA`.", + "", + f"Only {len(DRAWN)} of them carry anything, so the pictures in the layout", + "repo draw a 3x5 + 3. See *Not drawn* below for the rest.", + ] + + +def section_flags(promethium: bool, terms: dict) -> list[str]: + rows = [["RAMO_USE_HD_PROMETHIUM", + "set" if promethium else "unset", + "Hands Down Promethium" if promethium else "Hands Down Gold"]] + rows += [[name, str(value), note] for name, value, note in ( + ("TAPPING_TERM", terms["TAPPING_TERM"], + "hold to get the mod, the layer, the bracket pair"), + ("RAMO_ADAPTIVE_TERM", terms["RAMO_ADAPTIVE_TERM"], + "keydown to keydown, for an adaptive to fire"))] + return ["## Build flags and timings", "", + "All from `config.h`, read at generation time.", ""] + \ + table(["define", "value", "effect"], rows) + + +def section_files() -> list[str]: + return ["## Where each feature lives", "", + "All paths are relative to `users/raphaelmor/`.", ""] + \ + table(["feature", "file"], [ + ["layer list and order", "ramo_layers.h"], + ["key and colour per position", "layers/{CMK,HDP,HDG,MED,NAV,MOS," + "SYM,NUM,FUN}-defs.h"], + ["the keymap itself", "../../keyboards/crkbd/rev4_1/standard/" + "keymaps/raphaelmor/keymap.c"], + ["combos", "ramo_combos.c"], + ["adaptive keys", "ramo_adaptive.c"], + ["linger brackets, Caps Word", "ramo_linger.c"], + ["Shift+Bksp, comma-leader shift", "ramo_process_record.c"], + ["base switch (tap dance)", "ramo_tap_dance.c"], + ["per-key RGB", "ramo_ledmap.c"], + ]) + + +def section_tooling() -> list[str]: + return [ + "## Regenerating the docs", + "", + "```", + "python3 tools/kle_docs.py # this file and the layout repo", + "python3 tools/kle_export.py # corne-v4-1-ramo.json, for KLE", + "```", + "", + "`kle_export.py` runs the C preprocessor over `ramo_layers.h`, so it reads", + "the same keymap the firmware builds. It aborts instead of writing a wrong", + "file: an unknown keycode, a combo whose keys do not resolve, or a legend", + "too long for a cap all stop the export.", + "", + "To open it in [keyboard-layout-editor.com](http://www.keyboard-layout-editor.com),", + "use the **Upload** button and pick `corne-v4-1-ramo.json`. Do not paste that", + "file into the **Raw data** tab — that tab wraps whatever you type in `[ ]` of", + "its own, so a whole `.json` file ends up double-wrapped and draws one empty", + "key. Paste this instead:", + "", + "```", + "python3 tools/kle_export.py --raw -o /tmp/kle-raw.txt && pbcopy < /tmp/kle-raw.txt", + "```", + ] + + +def notes_for(source: dict) -> str: + return build_notes(source["layers"], source["keys"], source["combos"], + source["adaptives"], source["activators"], + source["hidden"]) + + +def document_name(promethium: bool) -> str: + return ("Corne v4.1 · raphaelmor " + + ("Colemak + Hands Down Promethium" if promethium + else "Colemak + Hands Down Gold")) + + +# GitHub URLs use blob/HEAD so they follow whatever the default branch is. +QMK_REPO = "https://github.com/raphaelmor/qmk_userspace" +ZMK_REPO = "https://github.com/raphaelmor/zmk-config" +QMK_DOC = f"{QMK_REPO}/blob/HEAD/users/raphaelmor/README.md" + + +def layout_doc(source: dict, image: str) -> str: + """The layout itself, with no board in it. For the keyboard-layout repo. + + `image` is a format string taking the layer prefix, so the caller decides + where the pictures sit relative to the file it is writing. + """ + layers, keys = source["layers"], source["keys"] + terms = read_terms() + base_prefixes = [p for _, p in layers[:2]] + + out = section_grid() + [""] + section_alphas(layers, keys) + [""] + out += ["## Every layer", ""] + for enum, prefix in layers: + role = "base" if prefix in base_prefixes else "layer" + out += [f"### {display_name(enum)} — {role}", "", + f"![{display_name(enum)} layer]({image.format(prefix=prefix.lower())})", + ""] + for block in (section_reaching(layers, source["activators"]), + section_combos(layers, keys, source["combos"]), + section_adaptives(layers, source["adaptives"], terms), + section_always_on(terms)): + out += block + [""] + + out += [ + "## Implementations", + "", + f"- **Corne v4.1 on QMK** — [{QMK_REPO.split('/')[-1]}]({QMK_DOC}). " + "The reference implementation; this page is generated from it.", + f"- **Totem on ZMK** — [{ZMK_REPO.split('/')[-1]}]({ZMK_REPO}). " + "38 keys, so it keeps `LB5` and `RB5`. Its own doc is still to write.", + ] + return "\n".join(out) + + +def qmk_doc(source: dict) -> str: + """What is true of this board and this firmware, and nothing else.""" + out: list[str] = [] + for block in (section_board(), + section_not_drawn(source["hidden"]), + section_flags(source["promethium"], read_terms()), + section_files(), + section_tooling()): + out += block + [""] + return "\n".join(out[:-1]) + + +def one_board(source: dict, enum: str, prefix: str) -> list: + """A KLE document holding a single layer and no title decal. + + The README wants one image per layer with a markdown heading above it, so + the decal that labels a board inside the stacked document is left out here. + """ + sheet = Sheet() + layer_board(sheet, 0.0, source["keys"][prefix], + set(source["activators"].get(enum, {}))) + rows = sheet.rows() + round_trip(sheet, rows, f"{prefix} board") + return [{"backcolor": L.BOARD_BG, + "name": f"{display_name(enum)} · {prefix}", + "plate": True}] + rows + + +def build(promethium: bool) -> list: + source = read_all(promethium) + layers, keys = source["layers"], source["keys"] + base_prefixes = [p for _, p in layers[:2]] + + sheet = Sheet() + y = 0.0 + for enum, prefix in layers: + role = "base" if prefix in base_prefixes else "layer" + title(sheet, y, display_name(enum), f"{role} · {prefix}") + layer_board(sheet, y, keys[prefix], + set(source["activators"].get(enum, {}))) + y += BOARD_PITCH + + rows = sheet.rows() + round_trip(sheet, rows, "board") + + head = { + "backcolor": L.BOARD_BG, + "name": document_name(promethium), + "notes": notes_for(source), + "plate": True, + } + return [head] + rows + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("-o", "--output", type=Path, + default=USERSPACE / "corne-v4-1-ramo.json") + ap.add_argument("--raw", action="store_true", + help="write the form KLE's Raw data tab wants: the same rows " + "with no enclosing brackets. That tab adds its own, so a " + "whole .json file pasted there parses as one empty key.") + ap.add_argument("--gold", action="store_true", + help="render Hands Down Gold instead of Promethium") + args = ap.parse_args() + + try: + document = build(promethium=not args.gold) + except ExportError as err: + print(f"kle_export: {err}", file=sys.stderr) + return 1 + + if args.raw: + # One row per line, no outer [ ]. This is what the Raw data tab shows. + text = ",\n".join(json.dumps(row, ensure_ascii=False) + for row in document) + "\n" + else: + text = json.dumps(document, indent=2, ensure_ascii=False) + "\n" + args.output.write_text(text) + + keys = sum(1 for row in document[1:] for item in row if isinstance(item, str)) + print(f"wrote {args.output} — {keys} keys" + + (" (Raw data form)" if args.raw else "")) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/users/raphaelmor/tools/kle_legends.py b/users/raphaelmor/tools/kle_legends.py new file mode 100644 index 00000000..2893b27b --- /dev/null +++ b/users/raphaelmor/tools/kle_legends.py @@ -0,0 +1,259 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Raphaël Mor +"""Display data for kle_export.py: keycode legends and hand-written notes. + +Everything here is presentation. The structure of the keymap comes from the C +sources; this module only decides how a resolved keycode should read on a keycap. +""" + +# --- Key appearance ---------------------------------------------------------- +# No colour. One neutral cap for everything, and a ghosted cap for a position +# that has nothing bound to it, so the shape of each layer reads at a glance. +# The greys are the ones from the original hand-made +# corne-v4-1-miryoku-adaptation.json. + +KEY_COLOR = "#424242" +KEY_TEXT = "#ffffff" +# The key you hold to reach a layer, shown on that layer's own board. Ghosted, +# because it is how you got here rather than something the layer binds. +ACTIVATOR_COLOR = "#8c5656" + +UNBOUND = {"KC_NO", "KC_TRNS"} + + +def is_unbound(keycode: str) -> bool: + return keycode.strip() in UNBOUND + + +# --- Board chrome ------------------------------------------------------------ + +BOARD_BG = "#626262" +TITLE_COLOR = "#424242" # a decal draws no cap, so this only matters if d is off +TITLE_TEXT = "#ffffff" + + +# --- Keycode legends --------------------------------------------------------- +# A legend is either a string (one centred label) or a (top, bottom) pair for a +# key whose shifted glyph is worth showing. + +BASIC: dict[str, object] = { + "KC_NO": "", + "KC_TRNS": "▽", + + # letters and digits fill themselves in below + "KC_QUOT": ('"', "'"), + "KC_COMM": ("<", ","), + "KC_DOT": (">", "."), + "KC_SLSH": ("?", "/"), + "KC_SCLN": (":", ";"), + "KC_MINS": ("_", "-"), + "KC_EQL": ("+", "="), + "KC_LBRC": ("{", "["), + "KC_RBRC": ("}", "]"), + "KC_BSLS": ("|", "\\"), + "KC_GRV": ("~", "`"), + + "KC_DQUO": '"', + "KC_COLN": ":", + "KC_UNDS": "_", + "KC_PLUS": "+", + "KC_TILD": "~", + "KC_PIPE": "|", + "KC_EXLM": "!", + "KC_QUES": "?", + "KC_AT": "@", + "KC_HASH": "#", + "KC_DLR": "$", + "KC_PERC": "%", + "KC_CIRC": "^", + "KC_AMPR": "&", + "KC_ASTR": "*", + "KC_LPRN": "(", + "KC_RPRN": ")", + "KC_LCBR": "{", + "KC_RCBR": "}", + + "KC_ESC": "Esc", + "KC_TAB": "Tab", + "KC_SPC": "␣", + "KC_ENT": "⏎", + "KC_BSPC": "⌫", + "KC_DEL": "⌦", + "KC_INS": "Ins", + "KC_APP": "▤", + "KC_PSCR": "PrtSc", + "KC_SCRL": "ScrLk", + "KC_PAUS": "Pause", + + "KC_LEFT": "←", + "KC_DOWN": "↓", + "KC_UP": "↑", + "KC_RGHT": "→", + "KC_RIGHT": "→", + "KC_HOME": "Home", + "KC_END": "End", + "KC_PGUP": "PgUp", + "KC_PGDN": "PgDn", + + "KC_LCTL": "⌃", + "KC_RCTL": "⌃", + "KC_LALT": "⌥", + "KC_RALT": "⌥", + "KC_ALGR": "AltGr", + "KC_LGUI": "⌘", + "KC_RGUI": "⌘", + "KC_LSFT": "⇧", + "KC_RSFT": "⇧", + "KC_HYPR": "Hyper", + "KC_MEH": "Meh", + + "KC_MPRV": "Prev", + "KC_MNXT": "Next", + "KC_MPLY": "Play", + "KC_MSTP": "Stop", + "KC_MUTE": "Mute", + "KC_VOLD": "Vol−", + "KC_VOLU": "Vol+", + + "MS_LEFT": "Ms ←", + "MS_DOWN": "Ms ↓", + "MS_UP": "Ms ↑", + "MS_RGHT": "Ms →", + "MS_WHLL": "Wh ←", + "MS_WHLD": "Wh ↓", + "MS_WHLU": "Wh ↑", + "MS_WHLR": "Wh →", + "MS_BTN1": "LMB", + "MS_BTN2": "RMB", + "MS_BTN3": "MMB", + + "RM_TOGG": "RGB on", + "RM_NEXT": "RGB mode", + "RM_HUEU": "RGB hue", + "RM_SATU": "RGB sat", + "RM_VALU": "RGB val", + + "CW_TOGG": "Caps Word", + "QK_BOOT": "Boot", +} + +for _i in range(26): + BASIC["KC_" + chr(ord("A") + _i)] = chr(ord("A") + _i) +for _d, _shifted in zip("1234567890", "!@#$%^&*()"): + BASIC["KC_" + _d] = (_shifted, _d) +for _n in range(1, 25): + BASIC[f"KC_F{_n}"] = f"F{_n}" + +# Modifier wrappers: keycode prefix -> the symbol shown on the front face. +MOD_TAPS = { + "LCTL_T": "⌃", "RCTL_T": "⌃", + "LALT_T": "⌥", "RALT_T": "⌥", + "LGUI_T": "⌘", "RGUI_T": "⌘", + "LSFT_T": "⇧", "RSFT_T": "⇧", + "HYPR_T": "Hyper", "MEH_T": "Meh", + "ALL_T": "Hyper", +} + +# Chord wrappers: keycode prefix -> prefix string put in front of the inner legend. +CHORDS = { + "LCTL": "⌃", "C": "⌃", + "LALT": "⌥", "A": "⌥", + "LGUI": "⌘", "G": "⌘", "LCMD": "⌘", + "LSFT": "⇧", "S": "⇧", + "SCMD": "⇧⌘", "SGUI": "⇧⌘", + "RCTL": "⌃", "RALT": "⌥", "RGUI": "⌘", "RSFT": "⇧", + "HYPR": "Hyper+", "MEH": "Meh+", +} + +# Layer enum -> short name shown on a layer-tap front face. +LAYER_NAMES = { + "L_COLEMAK": "Colemak", + "L_HANDSDOWN": "HandsDown", + "L_MEDIA": "Media", + "L_NAV": "Nav", + "L_MOUSE": "Mouse", + "L_SYM": "Sym", + "L_NUM": "Num", + "L_FUN": "Fun", +} + +# Tap-dance enum -> what a double tap does. Short: this goes on a 1u cap. +TAP_DANCES = { + "RAMO_TD_CMK": "2× CMK", + "RAMO_TD_HD": "2× HDP", +} + + +# --- Hand-written notes ------------------------------------------------------ +# COMBO_ACTION combos run C code, so their effect cannot be read off key_combos[]. +# Keyed by the enum name in ramo_combos.c. + +COMBO_ACTION_NOTES = { + "HD_COMBO_Th": "th", + "CMK_COMBO_Th": "th", + "HD_COMBO_QU": "qu", +} + +# What each combo produces. Kept short — these go on a 1u keycap. The dead-key +# combos show the dead key and the letter it makes: "´é" is ⌥E, then e. +COMBO_RESULT_NOTES = { + "HD_COMBO_ACUT": "´é", + "HD_COMBO_GRV": "`è", + "HD_COMBO_CIRC": "ˆê", + "HD_COMBO_UMLA": "¨ü", + "HD_COMBO_TILDE": "˜ñ", + "HD_COMBO_CEDIL": "ç", + "HD_COMBO_OE": "œ", + "HD_COMBO_AE": "æ", + "HD_COMBO_STAB": "⇧Tab", + "HD_COMBO_APP": "▤", + "HD_COMBO_CAPW": "CapsW", +} + +# The long form, for the list board. Anything absent falls back to the short form. +COMBO_LONG_NOTES = { + "HD_COMBO_ACUT": "acute dead key (⌥E) — é", + "HD_COMBO_GRV": "grave dead key (⌥`) — è", + "HD_COMBO_CIRC": "circumflex dead key (⌥I) — ê", + "HD_COMBO_UMLA": "umlaut dead key (⌥U) — ü", + "HD_COMBO_TILDE": "tilde dead key (⌥N) — ñ", + "HD_COMBO_CEDIL": "ç (⌥C)", + "HD_COMBO_OE": "œ (⌥Q)", + "HD_COMBO_AE": "æ (⌥')", + "HD_COMBO_STAB": "Shift-Tab", + "HD_COMBO_APP": "App menu", + "HD_COMBO_QU": "qu — hold past 200 ms to drop the u and leave q", + "HD_COMBO_CAPW": "Caps Word on or off", +} + +# Behaviors that are always on. These live in process_record / matrix_scan, not in +# any table a parser could read, so they are transcribed here. +# +# The last two fields are a file and a string to find in it. The generator turns +# them into a file:line anchor, so the line number cannot rot when the file moves +# — and if the string ever disappears, the export stops rather than lying. +# {TAPPING_TERM} is filled in from config.h. +ALWAYS_ON = [ + ("Linger brackets", + "Tap ( or {{ for one bracket. Hold past {TAPPING_TERM} ms for the pair, " + "caret inside: (|)", + "ramo_linger.c", "bool linger_track("), + ("Caps Word", + "Letters get ⇧. Digits, -, _, ⌫ keep it on. " + "Space → _, double space → real space + off.", + "ramo_linger.c", "bool caps_word_press_user("), + ("Shift + ⌫ = ⌦", + "Keeps Delete reachable on Hands Down, where it left the thumb row.", + "ramo_process_record.c", "keycode == KC_BSPC"), + ("Comma-leader shift", + "Hands Down only. Type , then a letter within {TAPPING_TERM} ms → " + "the comma goes, the letter gets ⇧.", + "ramo_process_record.c", "prior_keycode == KC_COMM"), + ("Combo hold on qu", + "Hold the W+M combo past {TAPPING_TERM} ms and the u is deleted, leaving q.", + "ramo_combos.c", "void combo_check_for_linger("), + ("Base switch", + "NAV layer, two left keys. Double-tap only, so a stray press cannot " + "change your base.", + "ramo_tap_dance.c", "ramo_TD_make_colemak_default"), +] diff --git a/users/raphaelmor/tools/kle_render.py b/users/raphaelmor/tools/kle_render.py new file mode 100644 index 00000000..e26938f4 --- /dev/null +++ b/users/raphaelmor/tools/kle_render.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Raphaël Mor +"""Draw a KLE document as an SVG, in KLE's own style, with the legends included. + +KLE's "Download SVG" produces good-looking keycaps and **no text at all** — the +export carries 296 keycap groups and zero nodes. So this module emits the +same markup KLE does, copied from one of its exports, and then draws the legends +on top. + + python3 tools/kle_render.py corne-v4-1-ramo.json -o /tmp/board.svg + +Everything below is at KLE's native 54 px key unit, so the numbers match its +output one for one: + + shell x*54 + 1, y*54 + 1, w*54 - 2, h*54 - 2 rx 5 + plate shell + (6, 3), shell - (12, 12) rx 5, lightness + 0.05 + ghost shell only, no plate + decal nothing but its legends +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from xml.sax.saxutils import escape + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from kle_export import LABEL_MAP # noqa: E402 + +UNIT = 54 # KLE's key unit, in pixels +PLATE_DX, PLATE_DY = 6, 3 +PLATE_DW, PLATE_DH = 12, 12 +OUTER_PAD = 10 # around the plate rectangle +INNER_PAD = 5 # between the plate rectangle and the keys +TEXT_PAD = 1.5 +FRONT_SCALE = 0.78 # the front lip is much shorter than the keytop +DESCENDER = 0.22 # of an em; the tail of a comma has to stay on the keytop +BOTTOM_LIFT = 0.45 # of an em, on top of the descender. The front lip carries + # visual weight below the plate, so a legend pinned to the + # plate's bottom edge reads lower than it measures. + +# Copied verbatim from a KLE export, so the caps render the way KLE draws them. +STYLE = """ + + + + + + + + + + + + + + """ + + +def deserialize(document: list) -> tuple[dict, list[dict]]: + """KLE document -> (metadata, keys with absolute x/y/w and their properties).""" + meta = document[0] if isinstance(document[0], dict) else {} + rows = document[1:] if isinstance(document[0], dict) else document + + keys: list[dict] = [] + state = {"c": "#cccccc", "t": "#000000", "a": 4, "w": 1.0, "f": 3, + "d": False, "g": False} + y = 0.0 + for row in rows: + if isinstance(row, dict): + continue + x = 0.0 + for item in row: + if isinstance(item, dict): + x += item.get("x", 0) + y += item.get("y", 0) + for prop in ("c", "t", "a", "d", "g", "w", "f"): + if prop in item: + state[prop] = item[prop] + continue + keys.append({"x": x, "y": y, "w": state["w"], "labels": item, + "c": state["c"], "t": state["t"], "a": state["a"], + "f": state["f"], "d": state["d"], "g": state["g"]}) + x += state["w"] + state["w"] = 1.0 # KLE resets width and decal after every key + state["d"] = False + y += 1.0 + return meta, keys + + +def place(labels: str, align: int) -> dict[int, str]: + """Label string -> {visual position: text}, using KLE's label map.""" + out = {} + for slot, text in enumerate(labels.split("\n")): + if not text or slot >= 12: + continue + visual = LABEL_MAP[align][slot] + if visual >= 0: + out[visual] = text + return out + + +def keytop_color(color: str) -> str: + """KLE's keytop is the cap colour with 0.05 added to its HSL lightness. + + Checked against its own export: #424242 becomes #4f4f4f. + """ + if not (color.startswith("#") and len(color) == 7): + return color + rgb = [int(color[i:i + 2], 16) / 255 for i in (1, 3, 5)] + low, high = min(rgb), max(rgb) + lightness = (low + high) / 2 + target = min(1.0, lightness + 0.05) + if high == low: # grey: no hue to preserve + return "#" + "%02x" % round(target * 255) * 3 + delta = high - low + sat = delta / (2 - high - low if lightness > 0.5 else high + low) + hue = {high: 0.0} + if high == rgb[0]: + hue = ((rgb[1] - rgb[2]) / delta) % 6 + elif high == rgb[1]: + hue = (rgb[2] - rgb[0]) / delta + 2 + else: + hue = (rgb[0] - rgb[1]) / delta + 4 + chroma = (1 - abs(2 * target - 1)) * sat + second = chroma * (1 - abs(hue % 2 - 1)) + base = target - chroma / 2 + parts = [(chroma, second, 0), (second, chroma, 0), (0, chroma, second), + (0, second, chroma), (second, 0, chroma), (chroma, 0, second)] + return "#" + "".join("%02x" % round((c + base) * 255) + for c in parts[int(hue)]) + + +def wrap(text: str, size: float, room: float) -> list[str]: + """Split on a space when a legend is too wide. SVG will not wrap for us.""" + if len(text) * size * 0.55 <= room or " " not in text: + return [text] + words = text.split(" ") + best, score = 1, None + for cut in range(1, len(words)): + spread = abs(len(" ".join(words[:cut])) - len(" ".join(words[cut:]))) + if score is None or spread < score: + best, score = cut, spread + return [" ".join(words[:best]), " ".join(words[best:])] + + +def draw_text(x, y, anchor, text, size, color, room) -> list[str]: + lines = wrap(text, size, room) + step = size * 1.05 + top = y - step * (len(lines) - 1) / 2 + return [f'{escape(line)}' + for i, line in enumerate(lines)] + + +def keycap(key: dict) -> list[str]: + """One keycap group, in the markup KLE's own SVG export uses.""" + x, y = key["x"] * UNIT + 1, key["y"] * UNIT + 1 + w, h = key["w"] * UNIT - 2, UNIT - 2 + classes = ("ghosted keycap" if key["g"] + else " decal keycap" if key["d"] else " keycap") + out = [f""] + if not key["d"]: + shell = (f'x="{x:g}" y="{y:g}" width="{w:g}" height="{h:g}" ' + f'rx="5" fill="{key["c"]}"') + out += [f' ', + f' '] + if not key["g"]: + top = (f'x="{x + PLATE_DX:g}" y="{y + PLATE_DY:g}" ' + f'width="{w - PLATE_DW:g}" height="{h - PLATE_DH:g}" ' + f'rx="5" fill="{keytop_color(key["c"])}"') + out += [f' ', + f' '] + + # KLE stops here. Everything below is the text it does not export. + px, py = x + PLATE_DX, y + PLATE_DY + pw, ph = w - PLATE_DW, h - PLATE_DH + size = 6 + 2 * key["f"] + color = key["t"] if isinstance(key["t"], str) else "#000000" + color = color.split("\n")[0] or "#000000" + columns = [(px + TEXT_PAD, "start"), (px + pw / 2, "middle"), + (px + pw - TEXT_PAD, "end")] + # The front lip is only 9 px tall on a 52 px cap, so its legends are smaller + # than the ones on the keytop or they spill over the plate edge. + lip_top, lip_h = py + ph, h - PLATE_DY - ph + lip_size = size * FRONT_SCALE + rows = [(py + TEXT_PAD + size * 0.85, size), # top of the keytop + (py + ph / 2 + size * 0.35, size), # its middle + (py + ph - TEXT_PAD - size * (DESCENDER + BOTTOM_LIFT), size), + (lip_top + lip_h / 2 + lip_size * 0.35, lip_size)] + for visual, text in place(key["labels"], key["a"]).items(): + cx, anchor = columns[visual % 3] + baseline, font = rows[visual // 3] + room = pw - 2 * TEXT_PAD if visual % 3 == 1 else pw + out += [" " + line for line in + draw_text(cx, baseline, anchor, text, font, color, room)] + out.append("") + return out + + +def render(document: list) -> str: + meta, keys = deserialize(document) + content_w = max(k["x"] + k["w"] for k in keys) * UNIT + content_h = (max(k["y"] for k in keys) + 1) * UNIT + plate_w = content_w + 2 * INNER_PAD + plate_h = content_h + 2 * INNER_PAD + width = plate_w + 2 * OUTER_PAD + height = plate_h + 2 * OUTER_PAD + + parts = [ + f"", + STYLE, + f" ", + f' ', + f" ", + ] + for key in keys: + parts += [" " + line for line in keycap(key)] + parts += [" ", " ", ""] + return "\n".join(parts) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("input", type=Path) + ap.add_argument("-o", "--output", type=Path, default=Path("/tmp/kle.svg")) + args = ap.parse_args() + + args.output.write_text(render(json.loads(args.input.read_text()))) + print(f"wrote {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())