Merge pull request 'feat(q5_max): expand chord table and add cheatsheet generator' (#25) from feat/q5-chord-expansion-cheatsheet into main

Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
2026-09-23 20:40:42 +00:00
committed by Gitea
6 changed files with 1672 additions and 112 deletions
+399
View File
@@ -120,3 +120,402 @@ via*.json
!keyboards/keychron/*/firmware/*.bin
/.remember/tmp/save-session.pid
/.direnv/CACHEDIR.TAG
# ──────────────── Added by agents-init ──────────────────
# agents-init --agents
AGENTS/
/AGENTS.md
/CLAUDE.md
# ────────────────────────────────────────────────────────
# ──────────────── Added by agents-init ──────────────────
# agents-init --plugins
docs/superpowers
docs/plans
docs/specs
docs/devlogs
# ────────────────────────────────────────────────────────
# id: gitig-boilerplate-e76d1ca7170bfb8385be6293da3f57bc
# ╭──────────────────────────────────────────────────────────╮
# │ GitIgnore Boilerplate Template │
# ╰──────────────────────────────────────────────────────────╯
#
# ──────────────────── OS-Generated Files ────────────────────
# automatic backup files created by some editors (e.g., Vim, Emacs)
*~
# temporary files created if a process still has a handle to a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# MacOS junk
.DS_Store
Thumbs.db
# Linux trash folder which might appear on any partition or disk
.Trash-*
# files created when an open file is removed but is still being accessed
.nfs*
# ─────────────────── Debug/Temporary/Testing ────────────────
# Matches OLD / .OLD
[Oo][Ll][Dd]/
.[Oo][Ll][Dd]/
# Matches DISABLE / .DISABLE
[Dd][Ii][Ss][Aa][Bb][Ll][Ee]/
.[Dd][Ii][Ss][Aa][Bb][Ll][Ee]/
# Matches DISABLED / .DISABLED
[Dd][Ii][Ss][Aa][Bb][Ll][Ee][Dd]/
.[Dd][Ii][Ss][Aa][Bb][Ll][Ee][Dd]/
# Matches DEBUG / .DEBUG
[Dd][Ee][Bb][Uu][Gg]/
.[Dd][Ee][Bb][Uu][Gg]/
# Matches TMP / .TMP
[Tt][Mm][Pp]/
.[Tt][Mm][Pp]/
# Matches TEMP / .TEMP
[Tt][Ee][Mm][Pp]/
.[Tt][Ee][Mm][Pp]/
# Matches TEMPORARY / .TEMPORARY
[Tt][Ee][Mm][Pp][Oo][Rr][Aa][Rr][Yy]/
.[Tt][Ee][Mm][Pp][Oo][Rr][Aa][Rr][Yy]/
# Matches TESTING / .TESTING
[Tt][Ee][Ss][Tt][Ii][Nn][Gg]/
.[Tt][Ee][Ss][Tt][Ii][Nn][Gg]/
# ─────────────────── AI Sessions and Rules ──────────────────
# Matches CLAUDE.md, .claud*, etc.
[Cc][Ll][Aa][Uu][Dd][Ee].[Mm][Dd]
.[Cc][Ll][Aa][Uu][Dd]*
# Matches GEMINI.md, .gemin*, etc.
[Gg][Ee][Mm][Ii][Nn][Ii].[Mm][Dd]
.[Gg][Ee][Mm][Ii][Nn]*
# Matches ANTIGRAVITY.md, .antigrav*, etc.
[Aa][nN][Tt][Ii][Gg][Rr][Aa][Vv][Ii][Tt][Yy].[Mm][Dd]
.[Aa][Nn][Tt][Ii][Gg][Rr][Aa][Vv]*
# Matches AGENTS.md, .agents, .remember, etc.
[Aa][Gg][Ee][Nn][Tt][Ss].[Mm][Dd]
.[Aa][Gg][Ee][Nn][Tt][Ss]
.[Rr][Ee][Mm][Ee][Mm][Bb][Ee][Rr]
# ──────────────────── Planning Artifacts ───────────────────
# Catalog files generated by pre-implementation analysis passes
.superpowers
docs/superpowers
docs/specs
docs/devlogs
# ──────────────────────────────────────────────────────────────
# id: gi-patterns-54143085ec229f3cf907a5aa2a8fcade
# Created by https://www.toptal.com/developers/gitignore/api/linux
# Edit at https://www.toptal.com/developers/gitignore?templates=linux
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
# End of https://www.toptal.com/developers/gitignore/api/linux
# id: gi-patterns-0f32034d72ab434db92ec99a29f317cd
# Created by https://www.toptal.com/developers/gitignore/api/c
# Edit at https://www.toptal.com/developers/gitignore?templates=c
### C ###
# Prerequisites
*.d
# Object files
*.o
*.ko
*.obj
*.elf
# Linker output
*.ilk
*.map
*.exp
# Precompiled Headers
*.gch
*.pch
# Libraries
*.lib
*.a
*.la
*.lo
# Shared objects (inc. Windows DLLs)
*.dll
*.so
*.so.*
*.dylib
# Executables
*.exe
*.out
*.app
*.i*86
*.x86_64
*.hex
# Debug files
*.dSYM/
*.su
*.idb
*.pdb
# Kernel Module Compile Results
*.mod*
*.cmd
.tmp_versions/
modules.order
Module.symvers
Mkfile.old
dkms.conf
# End of https://www.toptal.com/developers/gitignore/api/c
# id: gi-patterns-8d9b9f4f5eba6df00650424b9b029769
# Created by https://www.toptal.com/developers/gitignore/api/c++
# Edit at https://www.toptal.com/developers/gitignore?templates=c++
### C++ ###
# Prerequisites
*.d
# Compiled Object files
*.slo
*.lo
*.o
*.obj
# Precompiled Headers
*.gch
*.pch
# Compiled Dynamic libraries
*.so
*.dylib
*.dll
# Fortran module files
*.mod
*.smod
# Compiled Static libraries
*.lai
*.la
*.a
*.lib
# Executables
*.exe
*.out
*.app
# End of https://www.toptal.com/developers/gitignore/api/c++
# id: gi-patterns-e390e9c720b3dda36906a2b4cb76ffdd
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python
-107
View File
@@ -1,107 +0,0 @@
# CLAUDE.md - QMK Development (Keychron Q5 Max / K17 Max)
Guidelines and commands for the customized Keychron firmware project based on the `wireless_playground` fork.
## Project Scope
* **Origin:** [git.rootiest.dev/rootiest/qmk_firmware](https://git.rootiest.dev/rootiest/qmk_firmware)
* **Upstream:** [github.com/Keychron/qmk_firmware](https://github.com/Keychron/qmk_firmware) (branch: `wireless_playground`)
* **Primary Keyboard:** Keychron Q5 Max (ANSI Encoder)
* **Secondary Keyboard:** Keychron K17 Max (occasionally)
* **Development Branches:**
* `dev/q5` — Q5 Max work-in-progress, merges to `main`
* `dev/k17` — K17 Max work-in-progress, merges to `main`
* **Feature Goals:** Tap-Dance, expanded layers, advanced Chording, Unicode support, and Auto-correct.
## Build and Flash Commands
All commands must be run from the root of the repository **inside the project
Python virtual environment**. The system `qmk` is not used — activate the venv
first:
```bash
source .venv/bin/activate
```
Every `qmk` command below assumes the venv is active (or prefix each with
`source .venv/bin/activate &&`).
### Compilation
```bash
# Build the Q5 Max ANSI Encoder firmware
qmk compile -kb keychron/q5_max/ansi_encoder -km via
# Build the K17 Max firmware (rgb variant; separate 'white' LED variant exists)
qmk compile -kb keychron/k17_max/ansi_encoder/rgb -km via
```
### Flashing
```bash
# Flash the Q5 Max (requires the board to be in bootloader mode)
qmk flash -kb keychron/q5_max/ansi_encoder -km via
# Flash the K17 Max (requires the board to be in bootloader mode)
qmk flash -kb keychron/k17_max/ansi_encoder/rgb -km via
```
### Environment Setup
```bash
# Ensure the submodules are up to date (critical for the wireless_playground branch)
git submodule update --init --recursive
# Set the default keyboard/keymap
qmk setup
qmk config user.keyboard=keychron/q5_max/ansi_encoder
qmk config user.keymap=via
```
## Code Style and Patterns
* **Keymap Structure:** Keep the `keymap.c` organized by layers. Use descriptive defines for layer names (e.g., `_BASE`, `_FN`, `_CHORD`).
* **Feature Modules:** For advanced features like Chording or Tap-Dance, prefer creating separate headers/source files in the keymap folder to keep `keymap.c` readable.
* **Firmware Size:** Monitor the compiled `.bin` size, as wireless features and large feature sets (like Auto-correct) can quickly fill up flash memory.
* **Documentation:** Comment any complex chording logic or non-standard Tap-Dance implementations to ensure maintainability.
## Development Workflow
1. Verify the current branch is `dev/q5` (Q5 Max) or `dev/k17` (K17 Max).
2. Implement features in the relevant keymap directory:
* Q5 Max: `keyboards/keychron/q5_max/ansi_encoder/keymaps/via/`
* K17 Max: `keyboards/keychron/k17_max/ansi_encoder/rgb/keymaps/via/`
3. Test compilation locally before committing.
4. Ensure `rules.mk` has the necessary flags enabled (e.g., `TAP_DANCE_ENABLE = yes`, `UNICODE_ENABLE = yes`).
## Git Conventions
* Use conventional commits (`feat:`, `fix:`, `docs:`, `chore:`, etc.) scoped to the keyboard where relevant (e.g. `feat(q5_max):`, `fix(k17_max):`).
* Do **not** include `Co-Authored-By: Claude` trailers in commit messages.
### Chained / Stacked PRs
When merging a chain of PRs (e.g. `A → main`, `B → A`, `C → B`), always **delete the branch after each merge**. Gitea (and GitHub) will automatically retarget any open PRs pointing at the deleted branch to the branch it was merged into. This keeps the chain collapsing cleanly into `main` without manual retargeting or cleanup PRs.
## EEPROM Layout Notes
The Q5 Max uses wear-leveling EEPROM (STM32F401). Key layout facts:
* `EECONFIG_RGB_MATRIX` is at bytes 24–31; byte 0 packs `mode[7:2] | enable[1:0]`.
* Keychron custom RGB data (effect list, regions, per-key colours, retail demo flag) lives in `EECONFIG_KB_DATABLOCK` immediately after `EECONFIG_BASE_SIZE` (37 bytes).
* `VIA_EEPROM_MAGIC_ADDR` is pinned to **544** in `ansi_encoder/config.h`. Do not lower this value — it must stay above `EECONFIG_BASE_SIZE + EECONFIG_KB_DATA_SIZE`. If Keychron EEPROM grows, raise 544 accordingly and clear EEPROM on the board.
* `EECONFIG_KB_DATA_SIZE` is computed in `eeconfig_kb.h` and requires an `#undef` before the `#define` to suppress QMK's default-zero value.
## Keychron RGB (`KEYCHRON_RGB_ENABLE`)
Enabled via `KEYCHRON_RGB_ENABLE = yes` in `rules.mk`. Key behavioural notes:
* `eeconfig_init_custom_rgb()` **loads** Keychron RGB state from EEPROM into RAM. It must be called in `keyboard_post_init_kb()` and in `wireless_enter_connected_kb()`; without it the arrays are zero-initialised and Launcher settings are lost on every boot or transport change.
* `eeconfig_reset_custom_rgb()` **writes** defaults to EEPROM and stamps the version. The version stamp (`eeprom_update_dword(EECONFIG_KEYBOARD, ...)`) belongs here only — not in the load path.
* `kc_rgb_save()` must call `eeconfig_update_rgb_matrix()` to persist the QMK RGB mode alongside the Keychron custom data; otherwise `rgb_matrix_init()` (triggered on every transport change by `REINIT_LED_DRIVER = 1`) reloads the compile-time default `RGB_MATRIX_TYPING_HEATMAP`.
* `retail_demo_enable` is a single byte. A bug in the original Keychron code used `eeprom_read_block` instead of `eeprom_update_block` in `eeconfig_reset_custom_rgb()`, leaving `0xFF` on freshly-flashed boards. `retail_demo_task()` treats any non-zero value as "demo active" and forces `CUSTOM_MIXED_RGB` every scan. The load path now clamps `> 1` to `0` and re-writes the byte as a one-time recovery.
* `default_per_key_led[]` and `default_region[]` must be defined in board-specific code (e.g. `ansi_encoder.c`) — `keychron_rgb.c` declares them `extern`.
## DIP Switch (Win/Mac)
The Win-side dip switch uses a **frame overlay** rather than calling `rgb_matrix_mode()`. A `dip_win_active` flag is set on switch change; `rgb_matrix_indicators_advanced_user()` paints all LEDs white each frame when the flag is set. This avoids writing to EEPROM and preserves the Launcher-configured effect, which would otherwise be overwritten by the direct mode call.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,482 @@
<!-- Template for gen_cheatsheet.py — edit here, then regenerate cheatsheet.html -->
<meta charset="utf-8">
<title>Q5 Max Cheatsheet</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible+Next:wght@400;600;700&family=JetBrains+Mono:wght@500;700&family=Noto+Sans+Math&family=Noto+Sans+Symbols+2&display=swap">
<style>
:root {
--bg: #e8ebee;
--panel: #f5f6f8;
--ink: #15191e;
--muted: #5b6570;
--line: #d1d6dc;
--cap: #ffffff;
--cap-edge: #bfc6ce;
--accent: #0a66e8;
--accent-ink: #ffffff;
--warn: #a35a00;
--warn-bg: #fdf0dc;
--danger: #c62828;
--c-media: #0f8a7e;
--c-rgb: #b0369c;
--c-mouse: #3d8b2f;
--c-sys: #6450c8;
--c-custom: #d6336c;
--c-chord: #b8860b;
--c-mod: #5b6570;
--sans: "Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
--glyph: "Noto Sans Math", "Noto Sans Symbols 2", "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--bg: #111418; --panel: #191d22; --ink: #e6e9ed; --muted: #8e97a2; --line: #2a3038;
--cap: #252b32; --cap-edge: #0a0c0f; --accent: #4f9bff; --accent-ink: #0b1220;
--warn: #f0b35c; --warn-bg: #33270f; --danger: #ff6b6b;
--c-media: #3cc7b8; --c-rgb: #e070cf; --c-mouse: #7ccc6c; --c-sys: #a497ff; --c-custom: #ff5c8f; --c-chord: #e0b040; --c-mod: #8e97a2;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #111418; --panel: #191d22; --ink: #e6e9ed; --muted: #8e97a2; --line: #2a3038;
--cap: #252b32; --cap-edge: #0a0c0f; --accent: #4f9bff; --accent-ink: #0b1220;
--warn: #f0b35c; --warn-bg: #33270f; --danger: #ff6b6b;
--c-media: #3cc7b8; --c-rgb: #e070cf; --c-mouse: #7ccc6c; --c-sys: #a497ff; --c-custom: #ff5c8f; --c-chord: #e0b040; --c-mod: #8e97a2;
}
* { box-sizing: border-box; }
body { background: var(--bg); color: var(--ink); font: 16px/1.5 var(--sans); }
.wrap { max-width: 1240px; margin: 0 auto; padding: 32px 20px 64px; display: grid; gap: 40px; }
h1, h2 { font-family: var(--mono); letter-spacing: -0.02em; margin: 0; text-wrap: balance; }
h1 { font-size: clamp(1.75rem, 4vw, 2.5rem); line-height: 1.1; }
h2 { font-size: 1.35rem; }
p { margin: 0; }
.muted { color: var(--muted); }
kbd {
font: 700 0.8em/1 var(--mono); display: inline-block; min-width: 1.7em; text-align: center;
padding: 0.35em 0.45em 0.3em; border-radius: 5px; background: var(--cap); color: var(--ink);
border: 1px solid var(--line); border-bottom: 3px solid var(--cap-edge);
}
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---- Header ---------------------------------------------------------- */
header { display: grid; gap: 18px; }
.eyebrow { font: 600 0.75rem/1 var(--mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
nav { display: flex; gap: 16px; flex-wrap: wrap; font-size: 0.95rem; }
nav a { color: var(--accent); text-decoration: none; font-weight: 600; }
nav a:hover { text-decoration: underline; }
.how { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; list-style: none; margin: 0; padding: 0; }
.how li { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; display: grid; gap: 6px; align-content: start; }
.how b { font-family: var(--mono); font-size: 0.8rem; color: var(--muted); letter-spacing: 0.06em; text-transform: uppercase; }
.how p { font-size: 0.95rem; }
/* ---- Search ---------------------------------------------------------- */
.finder { position: sticky; top: env(safe-area-inset-top, 0px); z-index: 5; background: var(--bg); padding-block: 12px; display: grid; gap: 10px; border-bottom: 1px solid var(--line); }
.searchrow { display: flex; gap: 12px; align-items: center; }
.search { flex: 1; display: flex; align-items: center; gap: 10px; background: var(--cap); border: 1.5px solid var(--line); border-radius: 10px; padding: 0 14px; }
.search:focus-within { border-color: var(--accent); }
.search input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--ink); font: 500 1.15rem var(--mono); padding: 12px 0; }
.search input::placeholder { color: var(--muted); font-family: var(--sans); font-weight: 400; }
.count { font: 500 0.85rem var(--mono); color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: thin; }
.chip { flex: none; border: 1px solid var(--line); background: var(--panel); border-radius: 99px; padding: 4px 11px; font-size: 0.85rem; cursor: pointer; white-space: nowrap; }
.chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.chip span { opacity: 0.65; font-variant-numeric: tabular-nums; margin-left: 4px; }
/* ---- Results --------------------------------------------------------- */
.results { display: grid; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 10px; }
.tile {
text-align: left; cursor: pointer; background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 12px 14px 12px; display: grid; grid-template-rows: auto auto 1fr auto; gap: 6px; min-height: 100%;
}
.tile:hover { border-color: var(--accent); }
.tile .top { display: flex; justify-content: space-between; align-items: start; gap: 6px; }
.glyph { font: 2.3rem/1.15 var(--glyph); }
.cp { font: 500 0.7rem var(--mono); color: var(--muted); padding-top: 4px; }
.seq { display: flex; gap: 3px; flex-wrap: wrap; }
.seq kbd { font-size: 0.85rem; }
.name { font-size: 0.85rem; color: var(--muted); line-height: 1.3; }
.alts { font: 500 0.78rem var(--mono); color: var(--muted); display: flex; flex-wrap: wrap; gap: 4px 10px; }
.alts s { opacity: 0.7; }
.flag { justify-self: start; font: 600 0.72rem/1.2 var(--sans); color: var(--warn); background: var(--warn-bg); padding: 3px 7px; border-radius: 5px; }
.empty { grid-column: 1 / -1; padding: 40px 0; text-align: center; color: var(--muted); }
.toast {
position: fixed; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%);
background: var(--ink); color: var(--bg); padding: 10px 18px; border-radius: 99px; font-weight: 600; z-index: 10;
}
/* ---- Keyboard -------------------------------------------------------- */
.layers { display: grid; gap: 16px; }
.tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.tab { display: inline-flex; align-items: center; gap: 8px; border: 1px solid var(--line); background: var(--panel); border-radius: 8px; padding: 7px 14px; cursor: pointer; font-weight: 600; }
.tab i { width: 10px; height: 10px; border-radius: 50%; background: var(--led); box-shadow: 0 0 8px var(--led); }
.tab[aria-selected="true"] { border-color: var(--led); background: color-mix(in oklab, var(--led) 14%, var(--panel)); }
.layer-meta { display: grid; gap: 4px; font-size: 0.95rem; }
.layer-meta b { font-weight: 600; }
.opts { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: center; font-size: 0.9rem; }
.opts label { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 14px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); }
.legend span::before { content: ""; width: 12px; height: 12px; border-radius: 3px; background: color-mix(in oklab, var(--cat) 40%, var(--cap)); border: 1px solid var(--cat); }
.legend span.solid::before { background: var(--cat); }
.legend span.multi::before { background: conic-gradient(var(--stops)); border-color: var(--line); }
.board { overflow-x: auto; padding-bottom: 6px; }
.case {
/* White Q5 Max with eggshell white keycaps: fixed physical colours, same in both themes. */
--egg: #f1f0ea; --egg-edge: #c9c7bd; --grey: #a6a898; --grey-edge: #7b7d6f; --green: #5f9a78; --green-edge: #3f6e53; --legend: #34383a;
min-width: 780px; background: linear-gradient(170deg, #f4f4f1, #d8d9d5); border-radius: 18px; padding: 1.4%;
box-shadow: inset 0 1px 0 #fff, inset 0 -2px 0 rgba(0,0,0,.08), 0 12px 30px -16px rgba(0,0,0,.45);
}
.kb { position: relative; container-type: inline-size; aspect-ratio: 20.5 / 6.25; background: #e4e5e3; border-radius: 8px; box-shadow: inset 0 1px 3px rgba(0,0,0,.12); }
.key { position: absolute; padding: 0.14%; }
.key > div {
--cat: var(--c-mod); --kcap: var(--egg); --kedge: var(--egg-edge);
width: 100%; height: 100%; border-radius: 0.55cqw; background: var(--kcap); color: var(--legend);
border: 1px solid rgba(0,0,0,.1); border-bottom-width: 0.35cqw; border-bottom-color: var(--kedge);
display: flex; flex-direction: column; justify-content: space-between; padding: 0.35cqw 0.45cqw;
font: 600 1.02cqw/1.1 var(--sans); overflow: hidden; transition: opacity .15s;
}
.key .s { opacity: 0.6; }
.key .l { word-break: break-word; }
/* The chord key is the one key the whole page revolves around: a solid fill instead of a tint. */
.key.tint.solid > div { background: var(--cat); color: #241c05; border-bottom-color: color-mix(in oklab, var(--cat) 60%, #000); }
.key.grey > div { --kcap: var(--grey); --kedge: var(--grey-edge); }
.key.green > div { --kcap: var(--green); --kedge: var(--green-edge); color: #111; }
.key.knob > div { border-radius: 50%; align-items: center; justify-content: center; text-align: center; background: radial-gradient(circle at 35% 30%, #ffffff, #c4c6c4); color: var(--legend); border-color: #a4a7a5; }
.key.tint > div { background: color-mix(in oklab, var(--cat) 26%, var(--kcap)); color: var(--legend); border-color: color-mix(in oklab, var(--cat) 65%, transparent); border-bottom-color: color-mix(in oklab, var(--cat) 55%, var(--kedge)); }
.key.trans > div { background: transparent; border: 1px dashed #9ba09d; color: #858b88; font-weight: 400; }
.key.same > div { opacity: 0.32; }
.key:hover > div, .key:focus-visible > div { opacity: 1; outline: 2px solid var(--accent); outline-offset: -1px; }
.key:focus-visible { outline: none; }
.keyinfo { min-height: 3em; font-size: 0.95rem; display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
.keyinfo code { font: 500 0.85rem var(--mono); background: var(--panel); border: 1px solid var(--line); padding: 2px 6px; border-radius: 5px; }
.notes { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 22px 28px; font-size: 0.92rem; }
.notes > div { border-top: 1px solid var(--line); padding-top: 10px; display: grid; gap: 6px; align-content: start; }
.notes b { font-family: var(--mono); font-size: 0.8rem; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); }
.stack { display: grid; gap: 14px; }
.stack > .search { max-width: 520px; }
.acres { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 4px 20px; font: 500 0.9rem/1.6 var(--mono); }
.acres div { display: flex; gap: 8px; min-width: 0; }
.acres .typo { color: var(--danger); text-decoration: line-through; text-decoration-thickness: 1px; }
.acres .b { color: var(--muted); opacity: 0.6; }
.acres p { grid-column: 1 / -1; font-family: var(--sans); color: var(--muted); }
.notes p { color: var(--muted); font-size: 0.85rem; }
.notes p.lead { color: var(--ink); font-size: inherit; }
.notes dl { display: grid; grid-template-columns: auto 1fr; gap: 5px 14px; margin: 2px 0 4px; align-items: center; }
.notes dt { white-space: nowrap; display: flex; align-items: center; gap: 4px; }
.notes dd { margin: 0; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--led); border: 1px solid var(--line); margin-right: 4px; }
footer { font-size: 0.85rem; color: var(--muted); }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<div class="wrap">
<header>
<div class="eyebrow">Keychron Q5 Max · ANSI encoder · via keymap</div>
<h1>Cheatsheet</h1>
<nav><a href="#layers">Layer maps</a><a href="#tricks">Key tricks</a><a href="#autocorrect">Autocorrect</a><a href="#symbols">Symbol chords</a></nav>
</header>
<section id="layers" class="layers">
<h2>Layer maps</h2>
<div class="tabs" id="tabs" role="tablist" aria-label="Layers"></div>
<div class="layer-meta" id="layermeta"></div>
<div class="opts">
<label for="dim"><input type="checkbox" id="dim" checked> Fade keys identical to Base</label>
<div class="legend" id="legend"></div>
</div>
<div class="board"><div class="case"><div class="kb" id="kb"></div></div></div>
<div class="keyinfo" id="keyinfo"><span class="muted">Hover or focus a key for details.</span></div>
</section>
<section id="tricks" class="stack">
<h2>Key tricks</h2>
<div class="notes" id="tricklist"></div>
</section>
<section id="autocorrect" class="stack">
<h2>Autocorrect</h2>
<p class="muted" id="acintro"></p>
<label class="search" for="ac">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>
<input id="ac" type="search" autocomplete="off" spellcheck="false" placeholder="Check a word — e.g. teh, recieve, definately" aria-label="Search autocorrect dictionary">
</label>
<div class="acres" id="acres" aria-live="polite"></div>
</section>
<section id="symbols" class="stack">
<h2>Symbol chords</h2>
<ol class="how" id="how"></ol>
<div class="finder">
<div class="searchrow">
<label class="search" for="q">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>
<input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search by name, sequence, or symbol — e.g. arrow, ≤, gree…" aria-label="Search symbols">
<kbd title="Press / to focus">/</kbd>
</label>
<span class="count" id="count"></span>
</div>
<div class="chips" id="chips" role="group" aria-label="Filter by category"></div>
</div>
<div class="results" id="results" aria-live="polite"></div>
</section>
<footer>Generated from <code>chord_unicode.c</code>, <code>keymap.c</code>, <code>autocorrect_dictionary.txt</code> and <code>info.json</code> by <code>gen_cheatsheet.py</code>. Click a symbol to copy it.</footer>
</div>
<div class="toast" id="toast" hidden></div>
<script>
const DATA = /*DATA*/null;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const C = DATA.consts;
// ---- How-to ---------------------------------------------------------------
$("how").innerHTML = `
<li><b>1 · Start</b><p>Hold <kbd>Fn1</kbd> and press <kbd>Alt</kbd> (left). Keep holding, or let go — you then have ${C.CHORD_TIMEOUT_MS / 1000} s to begin.</p></li>
<li><b>2 · Type</b><p>Type the letters. The symbol appears as soon as the sequence is unique. Up to ${C.CHORD_MAX_LEN} letters.</p></li>
<li><b>3 · Adjust</b><p><kbd>Enter</kbd> confirms now, <kbd>Backspace</kbd> removes a letter, <kbd>Esc</kbd> cancels. Output uses Linux <kbd>Ctrl</kbd><kbd>Shift</kbd><kbd>U</kbd> entry.</p></li>`;
// ---- Key tricks -------------------------------------------------------------
const K = DATA.keys;
// Each card: title, rows of [what you do, what happens], optional footnote.
const dot = (c) => `<i class="dot" style="--led:${c}"></i>`;
const LEDS = Object.fromEntries(DATA.layers.map((l) => [l.name, l.led]));
const tricks = [
["Caps key", [
["Tap", "<kbd>Esc</kbd>"],
["Hold", "<kbd>Ctrl</kbd>"],
["<kbd>Shift</kbd> + tap", "Caps Lock"],
["<kbd>Alt</kbd> + tap", "Caps Word"],
["<kbd>Super</kbd> + tap", "Autocorrect on / off"],
], "In Caps Word, holding Shift types lowercase."],
["Caps key light", [
[dot("#00c800") + "Green", "Caps Word on"],
[dot("#9600ff") + "Purple", "Autocorrect off"],
[dot("#ffffff") + "White", "Caps Lock on"],
]],
["Home / End", [
["Tap", "<kbd>Home</kbd>"],
["Double-tap", "<kbd>End</kbd>"],
], `The key right of Enter. Double-tap within ${K.HOME_END_MS} ms. On Fn1 it is a plain End.`],
["Fn keys", [
["Hold <kbd>Fn1</kbd> / <kbd>Fn2</kbd>", "Layer while held"],
[`Tap ${K.TAPPING_TOGGLE}×`, "Layer stays on"],
], `Hold time for dual-role keys: ${K.TAPPING_TERM} ms.`],
["Layer locks", [
["<kbd>1</kbd> – <kbd>4</kbd>", "Lock Fn1 – Fn4"],
["<kbd>5</kbd>", "Lock Control"],
["<kbd>0</kbd>", "Unlock, back to Base"],
["Locked number again", "Unlock, back to Base"],
], "Once locked on Fn1 – Fn4 the number row does this directly — no Fn hold needed. From Control, press 0.",
"From Base, hold <kbd>Fn1</kbd> or <kbd>Fn2</kbd>, then press:"],
["Escape hatch", [
["<kbd>,</kbd> <kbd>.</kbd> <kbd>/</kbd> together", "Clear locks, back to Base"],
], "Works on every layer, even where those keys are remapped."],
["Knob", [
["Base", "Volume"],
["Fn2", "Window switcher (Alt-Tab)"],
["Fn1 · Fn3 · Fn4", "Rewind / fast-forward"],
["Control", "Backlight brightness"],
], `Window switcher: Alt releases ${K.ALT_TAB_TIMEOUT} ms after the last click.`],
["Esc light", [
[dot("transparent") + "Off", "Base"],
...["Fn1", "Fn2", "Fn3", "Fn4", "Control"].map((n) => [dot(LEDS[n]) + ({ Fn1: "Blue", Fn2: "Green", Fn3: "Orange", Fn4: "Purple", Control: "Red" })[n], n]),
]],
["Win / Mac switch", [
["Win side", "Whole backlight white"],
["Mac side", "Effect chosen in Launcher"],
], "The chosen effect is kept; flipping back restores it."],
];
$("tricklist").innerHTML = tricks.map(([title, rows, note, lead]) => `<div><b>${title}</b>${lead ? `<p class="lead">${lead}</p>` : ""}<dl>${
rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl>${note ? `<p>${note}</p>` : ""}</div>`).join("");
// ---- Autocorrect --------------------------------------------------------------
const AC = DATA.autocorrect;
$("acintro").innerHTML = `${AC.length.toLocaleString()} typos are fixed as you type. Toggle with <kbd>Super</kbd>+tap Caps. <span class="b">|</span> marks a word edge — the typo only counts at the start or end of a word.`;
const edge = (w) => esc(w).replace(/^:/, '<span class="b">|</span>').replace(/:$/, '<span class="b">|</span>');
function renderAC() {
const q = $("ac").value.trim().toLowerCase();
if (!q) { $("acres").innerHTML = ""; return; }
const hits = AC.filter(([t, f]) => t.replace(/:/g, "").includes(q) || f.toLowerCase().includes(q));
const LIMIT = 60;
$("acres").innerHTML = hits.slice(0, LIMIT).map(([t, f]) =>
`<div><span class="typo">${edge(t)}</span><span class="b">→</span><span>${esc(f)}</span></div>`).join("")
+ (hits.length > LIMIT ? `<p>${hits.length - LIMIT} more — keep typing to narrow down.</p>` : "")
+ (hits.length ? "" : `<p>“${esc(q)}” is not in the dictionary, so autocorrect leaves it alone.</p>`);
}
$("ac").addEventListener("input", renderAC);
$("ac").value = "teh";
renderAC();
// ---- Symbols ----------------------------------------------------------------
const chords = DATA.chords.map((c) => ({
...c,
hay: [c.out, c.name, c.cat, ...c.cp, ...c.seqs.map((s) => s.s)].join(" ").toLowerCase(),
}));
const cats = [...new Set(chords.map((c) => c.cat))];
let activeCat = "All";
$("chips").innerHTML = ["All", ...cats].map((cat) => {
const n = cat === "All" ? chords.length : chords.filter((c) => c.cat === cat).length;
return `<button class="chip" data-cat="${esc(cat)}" aria-pressed="${cat === activeCat}">${esc(cat)}<span>${n}</span></button>`;
}).join("");
$("chips").addEventListener("click", (e) => {
const b = e.target.closest(".chip");
if (!b) return;
activeCat = b.dataset.cat;
for (const c of $("chips").children) c.setAttribute("aria-pressed", c === b);
render();
});
function score(c, q) {
if (!q) return 1;
const words = q.split(/\s+/).filter(Boolean);
if (!words.every((w) => c.hay.includes(w))) return 0;
let s = 1;
for (const { s: seq } of c.seqs) {
if (seq === q) s = Math.max(s, 100);
else if (seq.startsWith(q)) s = Math.max(s, 50);
}
if (c.out === q) s = Math.max(s, 100);
if (c.name.toLowerCase().startsWith(q)) s = Math.max(s, 20);
return s;
}
const caps = (seq) => [...seq].map((ch) => `<kbd>${esc(ch)}</kbd>`).join("");
let shown = [];
function tile(c) {
const [primary, ...rest] = c.seqs;
const alts = rest.map((s) => s.st === "early"
? `<s title="Fires as soon as “${esc(s.at)}” is typed; the rest is typed as plain text">${esc(s.s)}</s>`
: `<span>${esc(s.s)}</span>`).join("");
const wait = c.seqs.some((s) => s.st === "wait")
? `<span class="flag" title="A longer sequence starts with the same letters">Pause ${C.CHORD_DISAMBIG_MS} ms or press Enter</span>` : "";
return `<button class="tile" data-out="${esc(c.out)}" title="Copy ${esc(c.out)}">
<div class="top"><span class="glyph">${esc(c.out)}</span><span class="cp">${c.cp.join(" ")}</span></div>
<div class="seq" aria-label="Sequence ${esc(primary.s)}">${caps(primary.s)}</div>
<div class="name">${esc(c.name || c.cat)}${alts ? `<div class="alts">also ${alts}</div>` : ""}</div>
${wait}
</button>`;
}
function render() {
const q = $("q").value.trim().toLowerCase();
shown = chords
.filter((c) => activeCat === "All" || c.cat === activeCat)
.map((c) => [score(c, q), c]).filter(([s]) => s > 0)
.sort((a, b) => b[0] - a[0]).map(([, c]) => c);
$("count").textContent = `${shown.length} / ${chords.length}`;
$("results").innerHTML = shown.length ? shown.map(tile).join("")
: `<p class="empty">No symbol matches “${esc(q)}”. Try a name like “arrow” or “integral”.</p>`;
}
$("q").addEventListener("input", render);
$("q").addEventListener("keydown", (e) => {
if (e.key === "Enter" && shown[0]) copy(shown[0].out);
if (e.key === "Escape") { $("q").value = ""; render(); }
});
document.addEventListener("keydown", (e) => {
if (e.key === "/" && document.activeElement !== $("q")) { e.preventDefault(); $("q").focus(); }
});
$("results").addEventListener("click", (e) => {
const t = e.target.closest(".tile");
if (t) copy(t.dataset.out);
});
let toastTimer;
async function copy(text) {
let msg = `Copied ${text}`;
try { await navigator.clipboard.writeText(text); } catch { msg = "Copy blocked by the browser — select the symbol instead"; }
$("toast").textContent = msg;
$("toast").hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => ($("toast").hidden = true), 1400);
}
render();
// ---- Layers -----------------------------------------------------------------
const CAT = {
mod: ["Modifier / nav", "--c-mod"], media: ["Media & system", "--c-media"], rgb: ["Backlight", "--c-rgb"],
mouse: ["Mouse", "--c-mouse"], sys: ["Power & wireless", "--c-sys"], custom: ["Custom", "--c-custom"],
chord: ["Chord key", "--c-chord"], danger: ["Destructive", "--danger"],
};
const layers = DATA.layers;
const base = layers[0];
const W = 20.5, H = 6.25;
const LED = Object.fromEntries(layers.map((l) => [l.id, l.led]));
let current = 0;
const KNOB = base.keys.findIndex((k) => k.x === 19.5 && k.y === 0); // encoder push sits top-right
$("legend").innerHTML = Object.entries(CAT).filter(([k]) => k !== "mod")
.map(([k, [label, v]]) => `<span class="${k === "chord" ? "solid" : ""}" style="--cat:var(${v})">${label}</span>`).join("")
+ `<span class="multi" style="--stops:${layers.slice(1).map((l) => l.led).join(",")}">Layer key (colour of the layer it opens)</span>`;
$("tabs").innerHTML = layers.map((l, i) =>
`<button class="tab" role="tab" id="tab-${l.id}" style="--led:${l.led}" aria-selected="${i === current}" data-i="${i}"><i></i>${esc(l.name)}</button>`).join("");
$("tabs").addEventListener("click", (e) => {
const b = e.target.closest(".tab");
if (b) { current = +b.dataset.i; drawLayer(); }
});
$("dim").addEventListener("change", drawLayer);
// Physical keycap colour follows the Base keycode at that position (the cap never changes with the layer).
const GREEN_CAPS = new Set(["KC_ESC", "KC_ENT"]);
const GREY_CAPS = new Set([
"KC_F5", "KC_F6", "KC_F7", "KC_F8", "KC_DEL", "KC_GRV", "KC_BSPC", "KC_TAB", "KC_BSLS", "CAPS_MOD",
"KC_LSFT", "KC_RSFT", "KC_LCTL", "KC_LGUI", "KC_LALT", "TT(FN2)", "TT(FN1)", "KC_RCTL",
"KC_PGUP", "KC_PGDN", "TD(TD_HOME_END)",
]);
const capColour = (b) => GREEN_CAPS.has(b.kc) ? "green" : GREY_CAPS.has(b.kc) ? "grey" : "white";
function keyHTML(k, i, layer) {
const b = base.keys[i];
const shownKey = k.t ? b : k;
const knob = i === KNOB;
const cls = ["key"];
let cat = "";
if (k.t) cls.push("trans");
else if (k.to) { cls.push("tint"); cat = LED[k.to]; }
else if (k.c && k.c !== "mod") { cls.push("tint"); cat = `var(${CAT[k.c][1]})`; }
if (k.c === "chord") cls.push("solid");
if (knob) cls.push("knob");
else cls.push(capColour(b));
if (layer !== base && $("dim").checked && (k.t || k.kc === b.kc)) cls.push("same");
const style = `left:${(k.x / W) * 100}%;top:${(k.y / H) * 100}%;width:${(k.w / W) * 100}%;height:${(k.h / H) * 100}%`;
return `<div class="${cls.join(" ")}" style="${style}" tabindex="0" data-i="${i}">
<div style="${cat ? `--cat:${cat}` : ""}"><span class="s">${esc(shownKey.s || "")}</span><span class="l">${esc(shownKey.l || "")}</span></div></div>`;
}
function drawLayer() {
const l = layers[current];
for (const t of $("tabs").children) t.setAttribute("aria-selected", +t.dataset.i === current);
$("kb").innerHTML = l.keys.map((k, i) => keyHTML(k, i, l)).join("");
const via = l.via.length
? l.via.map((v) => `<b>${esc(v.l)}</b> on ${esc(v.from)} <span class="muted">(${esc(v.d)})</span>`).join(" · ")
: "Default layer";
$("layermeta").innerHTML = `
<p><span class="muted">Reached with:</span> ${via}</p>
<p><span class="muted">Knob:</span> turn left <b>${esc(l.enc[0])}</b> · turn right <b>${esc(l.enc[1])}</b> · press <b>${esc((l.keys[KNOB].t ? base.keys[KNOB] : l.keys[KNOB]).d)}</b>
<span class="muted">· ${esc(l.ledNote)}</span></p>`;
}
function showInfo(e) {
const el = e.target.closest(".key");
if (!el) return;
const i = +el.dataset.i;
const k = layers[current].keys[i];
const eff = k.t ? base.keys[i] : k;
$("keyinfo").innerHTML = k.t
? `<code>_______</code> Transparent — falls through to the layer below (Base: <b>${esc(eff.d)}</b>)`
: `<code>${esc(k.kc)}</code> <span>${esc(k.d)}</span>`;
}
$("kb").addEventListener("mouseover", showInfo);
$("kb").addEventListener("focusin", showInfo);
drawLayer();
</script>
@@ -29,6 +29,8 @@ static const chord_entry_t chord_table[] = {
// ---- Math / Science ----------------------------------------------------
{"deg", "°"}, // degree sign U+00B0
{"cels", "℃"}, // degree Celsius U+2103 (not "degc": would delay "deg")
{"fahr", "℉"}, // degree Fahrenheit U+2109
{"sqrt", "√"}, // square root U+221A
{"sqrd", "²"}, // superscript 2 U+00B2
{"cubd", "³"}, // superscript 3 U+00B3
@@ -177,6 +179,31 @@ static const chord_entry_t chord_table[] = {
{"wow", "😮"}, // face with open mouth U+1F62E
{"zip", "🤐"}, // zipper-mouth face U+1F910
{"nerdy", "🤓"}, // nerd face U+1F913
{"love", "😍"}, // heart eyes U+1F60D
{"kiss", "😘"}, // face blowing a kiss U+1F618
{"blush", "😊"}, // smiling, smiling eyes U+1F60A
{"smirk", "😏"}, // smirking face U+1F60F
{"eyeroll","🙄"}, // face with rolling eyes U+1F644
{"sweat", "😅"}, // grinning face w/ sweat U+1F605
{"rofl", "🤣"}, // rolling on the floor U+1F923
{"flip", "🙃"}, // upside-down face U+1F643 (not "upside": would delay "ups")
{"plead", "🥺"}, // pleading face U+1F97A
{"melt", "🫠"}, // melting face U+1FAE0
{"salute", "🫡"}, // saluting face U+1FAE1
{"hug", "🤗"}, // hugging face U+1F917
{"shush", "🤫"}, // shushing face U+1F92B
{"mind", "🤯"}, // exploding head U+1F92F
{"mad", "😠"}, // angry face U+1F620 (not "angry": would delay "ang")
{"hot", "🥵"}, // hot face U+1F975
{"cold", "🥶"}, // cold face U+1F976
{"puke", "🤮"}, // face vomiting U+1F92E
{"party", "🥳"}, // partying face U+1F973
{"cowboy", "🤠"}, // cowboy hat face U+1F920
{"halo", "😇"}, // smiling face with halo U+1F607 (not "angel": would delay "ang")
{"devil", "😈"}, // smiling face w/ horns U+1F608
{"horny", "😈"}, // slang alias
{"clown", "🤡"}, // clown face U+1F921
{"ghost", "👻"}, // ghost U+1F47B
// ---- Emoji : Gestures --------------------------------------------------
{"thup", "👍"}, // thumbs up U+1F44D
@@ -187,6 +214,12 @@ static const chord_entry_t chord_table[] = {
{"pray", "🙏"}, // folded hands U+1F64F
{"ok", "👌"}, // ok hand U+1F44C
{"point", "👉"}, // backhand index pointing right U+1F449
{"facepalm","🤦"}, // person facepalming U+1F926
{"flex", "💪"}, // flexed biceps U+1F4AA (not "muscle": would delay "mus")
{"praise", "🙌"}, // raising hands U+1F64C
{"shake", "🤝"}, // handshake U+1F91D
{"luck", "🤞"}, // crossed fingers U+1F91E
{"nail", "💅"}, // nail polish U+1F485
// ---- Emoji : Symbols ---------------------------------------------------
// Variation selectors (U+FE0F) are omitted for cross-app compatibility;
@@ -218,6 +251,13 @@ static const chord_entry_t chord_table[] = {
{"lock", "🔒"}, // lock U+1F512
{"robot", "🤖"}, // robot U+1F916
{"alien", "👽"}, // alien U+1F47D
{"broken", "💔"}, // broken heart U+1F494
{"sparkle","✨"}, // sparkles U+2728
{"drops", "💦"}, // sweat droplets U+1F4A6
{"cum", "💦"}, // slang alias
{"brain", "🧠"}, // brain U+1F9E0
{"trophy", "🏆"}, // trophy U+1F3C6
{"moai", "🗿"}, // moai U+1F5FF
// ---- Emoji : Nature ----------------------------------------------------
{"sun", "☀"}, // black sun U+2600
@@ -229,6 +269,9 @@ static const chord_entry_t chord_table[] = {
{"dog", "🐶"}, // dog face U+1F436
{"fox", "🦊"}, // fox face U+1F98A
{"bear", "🐻"}, // bear face U+1F43B
{"goat", "🐐"}, // goat U+1F410
{"unicorn","🦄"}, // unicorn U+1F984
{"rnbw", "🌈"}, // rainbow U+1F308 (not "rainbow": would delay "rain")
// ---- Emoji : Food & Objects --------------------------------------------
{"coffee","☕"}, // hot beverage U+2615
@@ -251,6 +294,20 @@ static const chord_entry_t chord_table[] = {
{"trash", "🗑"}, // wastebasket U+1F5D1
{"clock", "🕐"}, // one o'clock U+1F550
{"hour", "⏳"}, // hourglass with flowing sand U+23F3
{"eggplant","🍆"}, // eggplant U+1F346
{"cock", "🍆"}, // slang alias
{"dick", "🍆"}, // slang alias
{"peach", "🍑"}, // peach U+1F351
{"butt", "🍑"}, // slang alias
{"ass", "🍑"}, // slang alias
{"melon", "🍈"}, // melon U+1F348
{"boobs", "🍈"}, // slang alias
{"taco", "🌮"}, // taco U+1F32E
{"pussy", "🌮"}, // slang alias
{"vag", "🌮"}, // slang alias
{"wine", "🍷"}, // wine glass U+1F377
{"chair", "🪑"}, // chair U+1FA91
{"lmao", "🪑"}, // slang alias (TikTok "I'm dead" laugh)
};
// clang-format on
@@ -275,7 +332,7 @@ static uint16_t chord_timer = 0; // last activity timestamp
// whose output differs.
static bool chord_disambig = false;
static uint16_t chord_disambig_t = 0;
static uint8_t chord_disambig_i = 0;
static uint16_t chord_disambig_i = 0;
// ============================================================================
// Internal helpers
@@ -289,7 +346,7 @@ static void chord_reset(void) {
chord_disambig = false;
}
static void chord_output(uint8_t idx) {
static void chord_output(uint16_t idx) {
send_unicode_string(chord_table[idx].out);
chord_reset();
}
@@ -308,7 +365,7 @@ static int chord_check(bool *is_prefix) {
int exact_idx = -1;
bool any_match = false;
for (uint8_t i = 0; i < CHORD_TABLE_LEN; i++) {
for (uint16_t i = 0; i < CHORD_TABLE_LEN; i++) {
uint8_t slen = (uint8_t)strlen(chord_table[i].seq);
// Entry shorter than current buffer → can't match
@@ -346,7 +403,7 @@ static int chord_check(bool *is_prefix) {
// Walk again only for the is_prefix refinement.
if (*is_prefix) {
*is_prefix = false;
for (uint8_t i = 0; i < CHORD_TABLE_LEN; i++) {
for (uint16_t i = 0; i < CHORD_TABLE_LEN; i++) {
uint8_t slen = (uint8_t)strlen(chord_table[i].seq);
if (slen <= chord_buf_len) continue;
if (memcmp(chord_table[i].seq, chord_buf, chord_buf_len) != 0) continue;
@@ -492,7 +549,7 @@ bool process_chord(uint16_t keycode, keyrecord_t *record) {
// Start the disambiguation timer; output fires when it expires.
chord_disambig = true;
chord_disambig_t = timer_read();
chord_disambig_i = (uint8_t)idx;
chord_disambig_i = (uint16_t)idx;
}
// idx == -1: only prefix match(es) so far → keep collecting.
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Generate cheatsheet.html from chord_unicode.c, keymap.c, autocorrect_dictionary.txt and info.json.
Run from anywhere: python3 gen_cheatsheet.py
The output is a single self-contained HTML page (layer maps, key tricks, autocorrect and chord lookup).
"""
import json
import re
from pathlib import Path
HERE = Path(__file__).resolve().parent
OUT = HERE / "cheatsheet.html"
# ---------------------------------------------------------------------------
# Chord table
# ---------------------------------------------------------------------------
def parse_chords():
src = (HERE / "chord_unicode.c").read_text()
table = src[src.index("chord_table[] = {"):src.index("// clang-format on")]
entries, cat = [], "Other"
for line in table.splitlines():
m = re.match(r"\s*// ---- (.+?) -+\s*$", line)
if m:
cat = m.group(1).replace("Emoji : ", "Emoji · ")
continue
m = re.match(r'\s*\{"([^"]+)",\s*"([^"]+)"\},\s*(?://\s*(.*))?', line)
if m:
seq, out, comment = m.group(1), m.group(2), (m.group(3) or "")
name = re.sub(r"\s*U\+[0-9A-Fa-f]+.*$", "", comment).strip()
entries.append({"seq": seq, "out": out, "cat": cat, "name": name})
return entries
def simulate(seq, entries):
"""Replay process_chord()/chord_check() for one typed sequence.
Returns (status, fired_at): 'ok' fires on the last letter, 'wait' needs the
CHORD_DISAMBIG_MS pause (or Enter), 'early' fires before the sequence ends.
"""
for k in range(1, len(seq) + 1):
buf = seq[:k]
exact = next((e for e in entries if e["seq"] == buf), None)
if exact is None:
continue
conflict = any(e["seq"].startswith(buf) and len(e["seq"]) > k and e["out"] != exact["out"] for e in entries)
if not conflict:
return ("ok" if k == len(seq) else "early"), buf
if k == len(seq):
return "wait", buf
return "ok", seq
def build_chords():
entries = parse_chords()
groups = {}
for e in entries:
g = groups.setdefault(e["out"], {"out": e["out"], "cat": e["cat"], "name": "", "seqs": []})
if e["name"] and "alias" not in e["name"] and not g["name"]:
g["name"] = re.sub(r"\s*\(.*\)$", "", e["name"])
status, at = simulate(e["seq"], entries)
g["seqs"].append({"s": e["seq"], "st": status, "at": at})
for g in groups.values():
g["cp"] = ["U+%04X" % ord(c) for c in g["out"]]
g["seqs"].sort(key=lambda s: (s["st"] == "early", len(s["s"])))
return list(groups.values())
# ---------------------------------------------------------------------------
# Keymap
# ---------------------------------------------------------------------------
LAYER_INFO = {
"BASE": ("Base", "#8a94a0", "ESC LED off"),
"FN1": ("Fn1", "#0080ff", "ESC LED blue"),
"FN2": ("Fn2", "#00dc50", "ESC LED green"),
"FN3": ("Fn3", "#ff7800", "ESC LED orange"),
"FN4": ("Fn4", "#b400ff", "ESC LED purple"),
"KEEB_CTL": ("Control", "#ff2020", "ESC LED red"),
}
SHIFTED = {
"KC_GRV": ("~", "`"), "KC_1": ("!", "1"), "KC_2": ("@", "2"), "KC_3": ("#", "3"), "KC_4": ("$", "4"),
"KC_5": ("%", "5"), "KC_6": ("^", "6"), "KC_7": ("&", "7"), "KC_8": ("*", "8"), "KC_9": ("(", "9"),
"KC_0": (")", "0"), "KC_MINS": ("_", "-"), "KC_EQL": ("+", "="), "KC_LBRC": ("{", "["),
"KC_RBRC": ("}", "]"), "KC_BSLS": ("|", "\\"), "KC_SCLN": (":", ";"), "KC_QUOT": ('"', "'"),
"KC_COMM": ("<", ","), "KC_DOT": (">", "."), "KC_SLSH": ("?", "/"),
}
# keycode: (label, category, description)
LABELS = {
"KC_ESC": ("Esc", "mod", "Escape"), "KC_TAB": ("Tab", "mod", "Tab"), "KC_BSPC": ("Backspace", "mod", "Backspace"),
"KC_ENT": ("Enter", "mod", "Enter"), "KC_SPC": ("", "mod", "Space"), "KC_DEL": ("Del", "mod", "Delete"),
"KC_LSFT": ("Shift", "mod", "Left Shift"), "KC_RSFT": ("Shift", "mod", "Right Shift"),
"KC_LCTL": ("Ctrl", "mod", "Left Control"), "KC_RCTL": ("Ctrl", "mod", "Right Control"),
"KC_LGUI": ("Super", "mod", "Left GUI / Super"), "KC_LALT": ("Alt", "mod", "Left Alt"),
"KC_PSCR": ("PrtSc", "mod", "Print Screen"), "KC_CALC": ("Calc", "media", "Calculator"),
"KC_FIND": ("Find", "media", "Find"), "KC_PGUP": ("PgUp", "mod", "Page Up"), "KC_PGDN": ("PgDn", "mod", "Page Down"),
"KC_HOME": ("Home", "mod", "Home"), "KC_END": ("End", "mod", "End"),
"KC_UP": ("↑", "mod", "Up"), "KC_DOWN": ("↓", "mod", "Down"), "KC_LEFT": ("←", "mod", "Left"), "KC_RGHT": ("→", "mod", "Right"),
"KC_NUM": ("Num", "mod", "Num Lock"), "KC_PSLS": ("/", "", "Keypad /"), "KC_PAST": ("*", "", "Keypad *"),
"KC_PMNS": ("−", "", "Keypad −"), "KC_PPLS": ("+", "", "Keypad +"), "KC_PDOT": (".", "", "Keypad ."),
"KC_PENT": ("Enter", "mod", "Keypad Enter"),
"KC_MPLY": ("⏯", "media", "Play / pause"), "KC_MPRV": ("⏮", "media", "Previous track"),
"KC_MNXT": ("⏭", "media", "Next track"), "KC_MUTE": ("Mute", "media", "Mute"),
"KC_VOLD": ("Vol −", "media", "Volume down"), "KC_VOLU": ("Vol +", "media", "Volume up"),
"KC_MRWD": ("Rewind", "media", "Rewind"), "KC_MFFD": ("Fast fwd", "media", "Fast forward"),
"KC_BRID": ("Bright −", "media", "Screen brightness down"), "KC_BRIU": ("Bright +", "media", "Screen brightness up"),
"KC_MCTRL": ("Mission", "media", "Mission Control (macOS)"), "KC_LNPAD": ("Launch", "media", "Launchpad (macOS)"),
"KC_TASK": ("Tasks", "media", "Task view"), "KC_FILE": ("Files", "media", "File explorer"),
"KC_SLEP": ("Sleep", "sys", "System sleep"), "KC_PWR": ("Power", "sys", "System power"),
"RGB_TOG": ("LED ⏻", "rgb", "Backlight on/off"), "RGB_MOD": ("Effect →", "rgb", "Next backlight effect"),
"RGB_RMOD": ("Effect ←", "rgb", "Previous backlight effect"), "RGB_VAI": ("LED +", "rgb", "Backlight brighter"),
"RGB_VAD": ("LED −", "rgb", "Backlight dimmer"), "RGB_HUI": ("Hue +", "rgb", "Backlight hue up"),
"RGB_HUD": ("Hue −", "rgb", "Backlight hue down"), "RGB_SAI": ("Sat +", "rgb", "Backlight saturation up"),
"RGB_SAD": ("Sat −", "rgb", "Backlight saturation down"), "RGB_SPI": ("Speed +", "rgb", "Effect speed up"),
"RGB_SPD": ("Speed −", "rgb", "Effect speed down"),
"BT_HST1": ("BT 1", "sys", "Bluetooth host 1"), "BT_HST2": ("BT 2", "sys", "Bluetooth host 2"),
"BT_HST3": ("BT 3", "sys", "Bluetooth host 3"), "P2P4G": ("2.4 GHz", "sys", "2.4 GHz dongle"),
"BAT_LVL": ("Battery", "sys", "Show battery level on LEDs"), "NK_TOGG": ("NKRO", "sys", "Toggle N-key rollover"),
"QK_CLEAR_EEPROM": ("Clear EE", "danger", "Clear EEPROM — wipes all saved settings"),
"KC_MS_WH_UP": ("Wheel ↑", "mouse", "Scroll up"), "KC_MS_WH_DOWN": ("Wheel ↓", "mouse", "Scroll down"),
"KC_MS_UP": ("Ms ↑", "mouse", "Mouse up"), "KC_MS_DOWN": ("Ms ↓", "mouse", "Mouse down"),
"KC_MS_LEFT": ("Ms ←", "mouse", "Mouse left"), "KC_MS_RIGHT": ("Ms →", "mouse", "Mouse right"),
"KC_MS_BTN1": ("Click L", "mouse", "Left click"), "KC_MS_BTN2": ("Click R", "mouse", "Right click"),
"KC_MS_BTN3": ("Click M", "mouse", "Middle click"),
"CAPS_MOD": ("Esc ⁄ Ctrl", "custom", "Tap Esc · hold Ctrl · Shift+tap Caps Lock · Alt+tap Caps Word · Super+tap toggle Autocorrect"),
"TD(TD_HOME_END)": ("Home ⁄ End", "custom", "Tap Home · double-tap End"),
"CHORD_KEY": ("Chord", "chord", "Chord entry — type a sequence to output a symbol"),
"LCK_BASE": ("Unlock", "custom", "Clear layer locks, return to Base"),
"ALT_TAB_FWD": ("Alt-Tab →", "custom", "Cycle windows forward"), "ALT_TAB_BWD": ("Alt-Tab ←", "custom", "Cycle windows back"),
}
LAYER_OPS = {
"TT": ("", "Tap-toggle {n}: hold for momentary, tap 3× to toggle"),
"TG": ("⇄ ", "Toggle {n} on/off"),
"TO": ("→ ", "Switch to {n} (clears others)"),
"OSL": ("¹ ", "One-shot {n}: applies to the next key only"),
"MO": ("", "Hold for {n}"),
}
def describe(kc):
if kc == "_______":
return {"t": 1}
m = re.fullmatch(r"(TT|TG|TO|OSL|MO)\((\w+)\)", kc)
if m:
op, layer = m.groups()
name = LAYER_INFO[layer][0]
prefix, desc = LAYER_OPS[op]
return {"l": prefix + name, "c": "layer", "to": layer, "d": desc.format(n=name)}
m = re.fullmatch(r"LCK_(FN\d|CTL)", kc)
if m:
layer = "KEEB_CTL" if m.group(1) == "CTL" else m.group(1)
name = LAYER_INFO[layer][0]
return {"l": "🔒 " + name, "c": "layer", "to": layer, "d": f"Lock {name} on (press again to unlock)"}
if kc in SHIFTED:
top, bottom = SHIFTED[kc]
return {"l": bottom, "s": top, "c": "", "d": kc}
if kc in LABELS:
label, cat, desc = LABELS[kc]
return {"l": label, "c": cat, "d": desc}
m = re.fullmatch(r"KC_([A-Z])", kc)
if m:
return {"l": m.group(1), "c": "", "d": kc}
m = re.fullmatch(r"KC_(\d)", kc)
if m:
return {"l": m.group(1), "c": "", "d": kc}
m = re.fullmatch(r"KC_P(\d)", kc)
if m:
return {"l": m.group(1), "c": "", "d": "Keypad " + m.group(1)}
m = re.fullmatch(r"KC_F(\d+)", kc)
if m:
return {"l": "F" + m.group(1), "c": "", "d": "F" + m.group(1)}
return {"l": kc.replace("KC_", ""), "c": "", "d": kc}
def split_args(body):
args, depth, cur = [], 0, ""
for ch in body:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if ch == "," and depth == 0:
args.append(cur.strip())
cur = ""
else:
cur += ch
if cur.strip():
args.append(cur.strip())
return args
def build_layers():
src = (HERE / "keymap.c").read_text()
info = json.loads((HERE / "../../info.json").read_text())
geom = info["layouts"]["LAYOUT_ansi_101"]["layout"]
layers = []
for m in re.finditer(r"\[(\w+)\]\s*=\s*LAYOUT_ansi_101\(", src):
i, depth = m.end(), 1
while depth:
depth += {"(": 1, ")": -1}.get(src[i], 0)
i += 1
codes = split_args(src[m.end():i - 1])
assert len(codes) == len(geom), f"{m.group(1)}: {len(codes)} keycodes vs {len(geom)} positions"
keys = []
for kc, g in zip(codes, geom):
k = describe(kc)
k.update(kc=kc, x=g["x"], y=g["y"], w=g.get("w", 1), h=g.get("h", 1))
keys.append(k)
name = m.group(1)
layers.append({"id": name, "name": LAYER_INFO[name][0], "led": LAYER_INFO[name][1], "ledNote": LAYER_INFO[name][2], "keys": keys})
enc = dict((m.group(1), [describe(m.group(2))["l"], describe(m.group(3))["l"]])
for m in re.finditer(r"\[(\w+)\]\s*=\s*\{ENCODER_CCW_CW\((\w+),\s*(\w+)\)\}", src))
for layer in layers:
layer["enc"] = enc.get(layer["id"])
# Every key on another layer that leads here.
layer["via"] = [{"from": other["name"], "l": k["l"], "d": k["d"]}
for other in layers if other is not layer
for k in other["keys"] if k.get("to") == layer["id"]]
return layers
def main():
header = (HERE / "chord_unicode.h").read_text()
consts = {n: int(re.search(rf"#define {n} (\d+)", header).group(1))
for n in ("CHORD_MAX_LEN", "CHORD_TIMEOUT_MS", "CHORD_DISAMBIG_MS")}
keymap = (HERE / "keymap.c").read_text()
config = (HERE / "config.h").read_text() + (HERE / "../../config.h").read_text()
keys = {
"TAPPING_TERM": int(re.search(r"#define TAPPING_TERM (\d+)", config).group(1)),
"TAPPING_TOGGLE": int(re.search(r"#define TAPPING_TOGGLE (\d+)", config).group(1)),
"ALT_TAB_TIMEOUT": int(re.search(r"#define ALT_TAB_TIMEOUT (\d+)", keymap).group(1)),
"HOME_END_MS": int(re.search(r"case TD\(TD_HOME_END\):\s*return (\d+)", keymap).group(1)),
}
autocorrect = re.findall(r"^(\S+)\s*->\s*(.+?)\s*$", (HERE / "autocorrect_dictionary.txt").read_text(), re.M)
data = {"chords": build_chords(), "layers": build_layers(), "consts": consts, "keys": keys, "autocorrect": autocorrect}
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
template = (HERE / "cheatsheet.template.html").read_text().split("\n", 1)[1] # drop the template note
OUT.write_text(template.replace("/*DATA*/null", payload))
print(f"wrote {OUT.name}: {len(data['chords'])} symbols, {len(data['layers'])} layers, {len(autocorrect)} autocorrections, {keys}")
if __name__ == "__main__":
main()