Files
neovim-config/docs/cheatsheet/cheatsheet.template.html
T
rootiest e4243fc6b5 feat(cheatsheet): link Rootiest Pages favicon
Explicit icon links so the tab shows the pages.rootiest favicon instead of the browser fallback.
2026-10-05 18:34:05 -04:00

2083 lines
141 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- Template for gen_cheatsheet.py — edit here, then regenerate cheatsheet.html (this first line is dropped) -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Neovim Cheatsheet</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"><link rel="icon" href="/favicon.ico" sizes="any"><link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Every Vim motion, built-in key and keymap of this Neovim config, searchable and on an interactive keyboard.">
<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:ital,wght@0,400;0,600;0,700;1,400&family=JetBrains+Mono:ital,wght@0,500;0,700;1,500&display=swap">
<script>
// Apply a saved light/dark choice before first paint (no flash of the wrong theme).
try { const t = localStorage.getItem("nvim-cheatsheet-theme"); if (t === "light" || t === "dark") document.documentElement.setAttribute("data-theme", t); } catch (e) {}
</script>
<style>
:root {
color-scheme: light;
--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;
--danger-bg: #fde4e4;
--ok: #2f7d32;
--ok-bg: #e2f3e3;
--case: linear-gradient(170deg, #f4f4f1, #d8d9d5);
--plate: #e1e3e2;
/* Source colours: who defines a key. */
--s-builtin: #6b7480;
--s-neovim: #0f8a7e;
--s-config: #d6336c;
--s-plugin: #6a48d7;
/* Category hues (see CAT_HUE in the script). */
--h1: #1f6feb; --h2: #0f8a7e; --h3: #d1462f; --h4: #2f8a3a; --h5: #7c4ddb; --h6: #b7791f; --h7: #4b55d6;
--h8: #c03aa8; --h9: #0e7fa0; --h10: #6f7d17; --h11: #d6336c; --h12: #8a5a2b; --h13: #6b7280;
--sans: "Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
}
@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; --danger-bg: #3a1a1c; --ok: #7ccc6c; --ok-bg: #18301a;
--case: linear-gradient(170deg, #22272e, #15191e); --plate: #0e1114;
--s-builtin: #8e97a2; --s-neovim: #3cc7b8; --s-config: #ff5c8f; --s-plugin: #a497ff;
--h1: #5ea1ff; --h2: #3cc7b8; --h3: #ff7a5c; --h4: #7ccc6c; --h5: #b197ff; --h6: #e8b44c; --h7: #8f96ff;
--h8: #e070cf; --h9: #4fc3dc; --h10: #b5c85a; --h11: #ff5c8f; --h12: #d6a06a; --h13: #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; --danger-bg: #3a1a1c; --ok: #7ccc6c; --ok-bg: #18301a;
--case: linear-gradient(170deg, #22272e, #15191e); --plate: #0e1114;
--s-builtin: #8e97a2; --s-neovim: #3cc7b8; --s-config: #ff5c8f; --s-plugin: #a497ff;
--h1: #5ea1ff; --h2: #3cc7b8; --h3: #ff7a5c; --h4: #7ccc6c; --h5: #b197ff; --h6: #e8b44c; --h7: #8f96ff;
--h8: #e070cf; --h9: #4fc3dc; --h10: #b5c85a; --h11: #ff5c8f; --h12: #d6a06a; --h13: #8e97a2;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.5 var(--sans); overflow-x: hidden; }
.wrap { max-width: 1240px; margin: 0 auto; padding: 32px 16px 64px; display: grid; gap: 48px; min-width: 0; }
.wrap > * { min-width: 0; }
@media (min-width: 720px) { .wrap { padding-inline: 20px; } }
h1, h2, h3 { 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; }
h3 { font-size: 0.95rem; }
p { margin: 0; }
a { color: var(--accent); }
code { font: 500 0.85em var(--mono); overflow-wrap: anywhere; }
.muted { color: var(--muted); }
kbd {
font: 700 0.8em/1 var(--mono); display: inline-block; min-width: 1.7em; text-align: center; white-space: nowrap;
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; }
.skip { position: absolute; left: 16px; top: -60px; background: var(--accent); color: var(--accent-ink); padding: 8px 14px; border-radius: 8px; z-index: 20; }
.skip:focus { top: 12px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---- Header ---------------------------------------------------------- */
header { display: grid; gap: 18px; }
.eyebrow { font: 600 0.75rem/1.4 var(--mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
.titlebar { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: end; justify-content: space-between; }
nav { display: flex; gap: 8px 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; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; background: var(--panel); flex: none; }
.seg button { border: 0; background: transparent; padding: 6px 12px; cursor: pointer; font-size: 0.85rem; font-weight: 600; }
.kbctl { display: flex; flex-wrap: wrap; gap: 8px 12px; justify-self: start; }
.seg button + button { border-left: 1px solid var(--line); }
.seg button[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; list-style: none; margin: 0; padding: 0; }
.stats li { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; display: grid; gap: 4px; align-content: start; }
.stats b { font: 700 1.5rem/1.1 var(--mono); font-variant-numeric: tabular-nums; }
.stats span { font-size: 0.85rem; color: var(--muted); }
.stats kbd { font-size: 0.9rem; }
/* ---- Shared bits ------------------------------------------------------- */
.stack { display: grid; gap: 16px; }
.lede { color: var(--muted); max-width: 75ch; }
.search { display: flex; align-items: center; gap: 10px; background: var(--cap); border: 1.5px solid var(--line); border-radius: 10px; padding: 0 14px; min-width: 0; }
.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.1rem var(--mono); padding: 11px 0; }
.search input::placeholder { color: var(--muted); font-family: var(--sans); font-weight: 400; }
.search svg { flex: none; color: var(--muted); }
.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; align-items: center; }
.chips.wrapchips { flex-wrap: wrap; overflow: visible; }
.chips > .lbl { flex: none; font: 600 0.72rem var(--mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-right: 4px; }
.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; }
.chip i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--dot, var(--muted)); margin-right: 6px; vertical-align: 1px; }
.toggle { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; font-size: 0.9rem; flex: none; }
.toggle input { accent-color: var(--accent); width: 16px; height: 16px; }
.empty { padding: 32px 0; text-align: center; color: var(--muted); }
.compose { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 10px 14px; border-radius: 10px; border: 1px dashed var(--accent); background: color-mix(in oklab, var(--accent) 7%, var(--panel)); font-size: 0.95rem; }
.compose .keys kbd { font-size: 0.85rem; }
.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: 30;
max-width: calc(100vw - 32px); text-align: center;
}
.btn { border: 1px solid var(--line); background: var(--panel); border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 0.85rem; font-weight: 600; }
.btn:hover { border-color: var(--accent); }
.btn.big { padding: 10px 20px; font-size: 0.95rem; justify-self: center; }
/* Key sequences rendered as keycaps */
.keys { display: inline-flex; flex-wrap: wrap; gap: 3px; align-items: center; min-width: 0; vertical-align: middle; }
.keys kbd { font-size: 0.85rem; }
.keys .combo { display: inline-flex; gap: 1px; align-items: center; }
.keys kbd.mod { font-size: 0.68rem; color: var(--muted); padding-inline: 0.4em; }
.keys kbd.named { font-size: 0.74rem; }
.keys kbd.lead { background: color-mix(in oklab, var(--s-config) 18%, var(--cap)); border-color: color-mix(in oklab, var(--s-config) 55%, var(--line)); display: inline-flex; flex-direction: column; gap: 2px; padding-block: 0.25em 0.2em; }
.keys kbd.lead small { font: 600 0.55rem/1 var(--sans); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.keys .ph { font: italic 500 0.8rem var(--mono); color: var(--muted); border: 1px dashed var(--line); border-radius: 5px; padding: 2px 6px; }
.keys .reg { font: 500 0.75rem var(--mono); color: var(--muted); border: 1px dashed var(--line); border-radius: 5px; padding: 2px 5px; opacity: 0.85; }
.keys .exk { font: 700 0.95rem var(--mono); }
.keys .exk .opt { color: var(--muted); font-weight: 500; }
.badge { display: inline-flex; align-items: center; font: 600 0.68rem/1 var(--mono); padding: 3px 6px; border-radius: 5px; border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
.badge.m { min-width: 1.9em; justify-content: center; }
.src { display: inline-flex; align-items: center; gap: 5px; font: 600 0.72rem/1.2 var(--sans); padding: 3px 8px; border-radius: 99px; white-space: nowrap;
color: var(--sc); background: color-mix(in oklab, var(--sc) 13%, transparent); border: 1px solid color-mix(in oklab, var(--sc) 40%, transparent); }
.src.builtin { --sc: var(--s-builtin); } .src.neovim { --sc: var(--s-neovim); } .src.config { --sc: var(--s-config); } .src.plugin { --sc: var(--s-plugin); }
.catb { display: inline-flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--muted); white-space: nowrap; }
.catb::before { content: ""; width: 8px; height: 8px; border-radius: 2px; background: var(--cat); }
.ctx { font-size: 0.75rem; color: var(--muted); border: 1px dashed var(--line); border-radius: 99px; padding: 2px 8px; white-space: nowrap; }
.flag { display: inline-block; font: 600 0.75rem/1.35 var(--sans); padding: 3px 8px; border-radius: 6px; color: var(--warn); background: var(--warn-bg); }
.flag.rep { color: var(--muted); background: color-mix(in oklab, var(--muted) 12%, transparent); }
.flag.over { color: var(--s-config); background: color-mix(in oklab, var(--s-config) 12%, transparent); }
.flag.dead { color: var(--danger); background: var(--danger-bg); }
.flag.info { color: var(--muted); background: transparent; border: 1px solid var(--line); }
.flag b { font-weight: 700; }
.flag code { font-size: 0.72rem; }
.flag s { opacity: 0.8; font-weight: 500; }
/* ---- Finder rows -------------------------------------------------------- */
.finder { position: sticky; top: 0; z-index: 5; background: var(--bg); padding: 12px 0 10px; display: grid; gap: 10px; border-bottom: 1px solid var(--line); }
.searchrow { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; min-width: 0; }
.searchrow .search { flex: 1 1 260px; }
.filters { display: grid; gap: 8px; }
.opts { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: center; font-size: 0.9rem; }
.opts .seg button { font-size: 0.82rem; padding: 6px 11px; }
.scopehint { font-size: 0.85rem; color: var(--muted); flex: 1 1 240px; min-width: 0; }
.sel { font: 500 0.85rem var(--sans); color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 99px; padding: 4px 10px; cursor: pointer; flex: none; max-width: 100%; }
.sel.on { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
@media (min-width: 720px) { .chips.wrapwide { flex-wrap: wrap; overflow: visible; } }
.chips, .tabs, .wsubs { scrollbar-color: var(--line) transparent; }
@media (max-width: 560px) {
/* Phones: tab rows and window chips scroll sideways instead of stacking 4–10 rows deep. */
.tabs, .wsubs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; padding-bottom: 4px; }
.tab, .wsubs .chip { flex: none; }
#scope { display: grid; grid-template-columns: 1fr 1fr; width: 100%; gap: 1px; background: var(--line); }
#scope button + button { border-left: 0; }
#scope button:not([aria-pressed="true"]) { background: var(--panel); }
}
.results { display: grid; gap: 6px; }
.rcat { margin: 14px 0 2px; display: flex; gap: 8px; align-items: center; font: 600 0.78rem var(--mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
.rcat::before { content: ""; width: 10px; height: 10px; border-radius: 3px; background: var(--cat); }
.rcat:first-child { margin-top: 0; }
.row { display: grid; grid-template-columns: minmax(96px, 180px) minmax(0, 1fr); gap: 4px 16px; padding: 9px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; border-left: 4px solid var(--cat, var(--line)); align-items: start; }
.row.dimmed .rd { text-decoration: line-through; text-decoration-color: color-mix(in oklab, var(--muted) 70%, transparent); color: var(--muted); }
.rb { display: grid; gap: 5px; min-width: 0; }
.rd { font-size: 0.97rem; overflow-wrap: anywhere; }
.rm { display: flex; flex-wrap: wrap; gap: 5px 8px; align-items: center; }
.rm .fl { font: 500 0.74rem var(--mono); color: var(--muted); }
.rm a { font: 500 0.74rem var(--mono); text-decoration: none; }
.rm a:hover { text-decoration: underline; }
.rflags { display: flex; flex-wrap: wrap; gap: 5px; }
.rn { font-size: 0.86rem; color: var(--muted); overflow-wrap: anywhere; }
.rn kbd { font-size: 0.75rem; }
.rf { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: center; font: 500 0.75rem var(--mono); color: var(--muted); }
.rf a { text-decoration: none; }
.rf a:hover { text-decoration: underline; }
.copy { border: 1px solid var(--line); background: transparent; border-radius: 5px; padding: 1px 6px; cursor: pointer; font: 500 0.72rem var(--mono); color: var(--muted); }
.copy:hover { color: var(--ink); border-color: var(--accent); }
.keys .alt { color: var(--muted); font-size: 0.8rem; margin: 0 2px; }
@media (max-width: 640px) { .row { grid-template-columns: minmax(0, 1fr); } }
/* ---- What's different -------------------------------------------------- */
.notes { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 290px), 1fr)); gap: 22px 28px; font-size: 0.92rem; }
.notes > div { border-top: 3px solid var(--kind, var(--line)); padding-top: 10px; display: grid; gap: 6px; align-content: start; min-width: 0; }
.notes .change { --kind: var(--accent); } .notes .warn { --kind: var(--warn); } .notes .tip { --kind: var(--ok); }
.notes b.t { font-family: var(--mono); font-size: 0.8rem; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.notes b.t em { font-style: normal; font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; color: var(--kind); border: 1px solid var(--kind); letter-spacing: 0.08em; }
.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: fit-content(48%) minmax(0, 1fr); gap: 5px 14px; margin: 2px 0 4px; align-items: center; }
.notes dt { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.notes dd { margin: 0; overflow-wrap: anywhere; }
.notes kbd { font-size: 0.75rem; }
.difflists { display: grid; gap: 10px; }
.dl { display: grid; gap: 10px; }
.dl > p { color: var(--muted); font-size: 0.9rem; max-width: 80ch; }
details.dl { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 4px 16px; }
details.dl[open] { padding-bottom: 14px; }
details.dl > summary { cursor: pointer; padding: 10px 0; list-style-position: outside; margin-left: 18px; }
details.dl > summary h3 { display: inline-flex; gap: 10px; align-items: baseline; }
#notemore { justify-self: start; }
.dgroup { display: grid; gap: 2px; }
.dgroup h4 { margin: 10px 0 4px; font: 600 0.8rem var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); display: flex; gap: 8px; align-items: center; }
.drow { display: grid; grid-template-columns: minmax(110px, 190px) minmax(0, 1fr); gap: 4px 16px; padding: 8px 0; border-top: 1px solid var(--line); align-items: start; font-size: 0.92rem; }
.drow .was { color: var(--muted); text-decoration: line-through; text-decoration-color: color-mix(in oklab, var(--danger) 60%, transparent); }
.drow .arrow { color: var(--muted); margin: 0 6px; }
.drow .meta { display: flex; gap: 6px 10px; flex-wrap: wrap; align-items: center; margin-top: 3px; font: 500 0.72rem var(--mono); color: var(--muted); }
details.more > summary { cursor: pointer; color: var(--accent); font-weight: 600; font-size: 0.9rem; padding: 6px 0; }
@media (max-width: 560px) { .drow { grid-template-columns: minmax(0, 1fr); } }
/* ---- Keyboard ---------------------------------------------------------- */
.layers { display: grid; gap: 14px; }
.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 13px; cursor: pointer; font-weight: 600; font-size: 0.92rem; }
.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) 16%, var(--panel)); }
.layer-meta { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: center; font-size: 0.95rem; }
.layer-meta .grow { flex: 1 1 280px; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.85rem; }
.legend span { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); }
.legend span.c::before { content: ""; width: 12px; height: 12px; border-radius: 3px; background: color-mix(in oklab, var(--cat) 35%, var(--cap)); border: 1px solid var(--cat); }
.legend .mk { position: static; }
.board { overflow-x: auto; padding-bottom: 6px; -webkit-overflow-scrolling: touch; }
.case { min-width: 900px; background: var(--case); border-radius: 18px; padding: 1.2%; border: 1px solid var(--line);
box-shadow: inset 0 1px 0 color-mix(in oklab, #fff 50%, transparent), 0 12px 30px -16px rgba(0,0,0,.45); }
.kb { position: relative; container-type: inline-size; aspect-ratio: 18.25 / 6.25; background: var(--plate); border-radius: 8px; box-shadow: inset 0 1px 3px rgba(0,0,0,.14); }
.key { position: absolute; padding: 0.14%; margin: 0; border: 0; background: none; cursor: pointer; text-align: left; color: inherit; }
.key > .cap {
--cat: var(--h13);
position: relative; width: 100%; height: 100%; border-radius: 0.55cqw; background: var(--cap); color: var(--ink);
border: 1px solid var(--line); border-bottom-width: 0.35cqw; border-bottom-color: var(--cap-edge);
display: flex; flex-direction: column; justify-content: space-between; padding: 0.35cqw 0.45cqw 0.3cqw;
overflow: hidden; transition: opacity .15s, transform .08s;
}
.key .g { font: 700 1.3cqw/1.1 var(--mono); white-space: nowrap; }
.key .g small { font-size: 0.75em; color: var(--muted); font-weight: 500; }
.key .lb { font: 600 0.95cqw/1.12 var(--sans); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: break-word; hyphens: auto; }
.key.tint > .cap { background: color-mix(in oklab, var(--cat) 24%, var(--cap)); border-color: color-mix(in oklab, var(--cat) 60%, transparent); border-bottom-color: color-mix(in oklab, var(--cat) 50%, var(--cap-edge)); }
.key.none > .cap { opacity: 0.4; }
.key.none .lb { display: none; }
.key.modkey > .cap { background: color-mix(in oklab, var(--muted) 22%, var(--cap)); justify-content: center; }
.key.modkey .g { font-size: 1.05cqw; color: var(--muted); }
.key.modkey[aria-pressed="true"] > .cap { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
.key.modkey[aria-pressed="true"] .g { color: var(--accent-ink); }
.key.dead .lb { text-decoration: line-through; }
.key:hover > .cap, .key:focus-visible > .cap, .key.cur > .cap { opacity: 1; outline: 2px solid var(--accent); outline-offset: -1px; }
.key:focus-visible { outline: none; }
.key:active > .cap { transform: translateY(1px); }
.mk { position: absolute; top: 0.35cqw; right: 0.4cqw; width: 0.85cqw; height: 0.85cqw; min-width: 7px; min-height: 7px; border-radius: 50%; display: inline-block; }
.mk.live { background: var(--mkc, var(--s-config)); box-shadow: 0 0 0 1px var(--cap); }
.mk.builtin { border: 1.5px solid var(--s-builtin); }
.mk.group { border-radius: 2px; background: transparent; border: 1.5px dashed var(--muted); }
.mk.wait { box-shadow: 0 0 0 2px var(--warn); }
.legend .mk { width: 10px; height: 10px; }
/* Keyboard colourways: the keyboard has its own dark/light switch, independent of the page theme.
Dark is shared; light is a cool white-and-grey office board (standard) or the eggshell-white Q5 Max (as in the QMK sheet). */
.board[data-kb="dark"] {
--ink: #e6e9ed; --muted: #8e97a2; --line: #2a3038; --panel: #191d22;
--cap: #252b32; --cap-edge: #0a0c0f; --accent: #4f9bff; --accent-ink: #0b1220;
--case: linear-gradient(170deg, #22272e, #15191e); --plate: #0e1114;
--warn: #f0b35c; --warn-bg: #33270f; --danger: #ff6b6b; --danger-bg: #3a1a1c; --ok: #7ccc6c; --ok-bg: #18301a;
--s-builtin: #8e97a2; --s-neovim: #3cc7b8; --s-config: #ff5c8f; --s-plugin: #a497ff;
--h1: #5ea1ff; --h2: #3cc7b8; --h3: #ff7a5c; --h4: #7ccc6c; --h5: #b197ff; --h6: #e8b44c; --h7: #8f96ff;
--h8: #e070cf; --h9: #4fc3dc; --h10: #b5c85a; --h11: #ff5c8f; --h12: #d6a06a; --h13: #8e97a2;
}
.board[data-kb="light"][data-board="standard"] {
--ink: #1c2128; --muted: #5b6570; --line: #c3cad3; --panel: #f5f6f8;
--cap: #fafbfc; --cap-edge: #aab2bc; --accent: #0a66e8; --accent-ink: #ffffff;
--case: linear-gradient(170deg, #e4e8ed, #c4cbd4); --plate: #b9c1cb;
--warn: #a35a00; --warn-bg: #fdf0dc; --danger: #c62828; --danger-bg: #fde4e4; --ok: #2f7d32; --ok-bg: #e2f3e3;
--s-builtin: #6b7480; --s-neovim: #0f8a7e; --s-config: #d6336c; --s-plugin: #6a48d7;
--h1: #1f6feb; --h2: #0f8a7e; --h3: #d1462f; --h4: #2f8a3a; --h5: #7c4ddb; --h6: #b7791f; --h7: #4b55d6;
--h8: #c03aa8; --h9: #0e7fa0; --h10: #6f7d17; --h11: #d6336c; --h12: #8a5a2b; --h13: #6b7280;
}
.board[data-kb="light"][data-board="q5"] {
--ink: #34383a; --muted: #6a6e68; --line: #cfcdc3; --panel: #f5f6f8;
--cap: #f1f0ea; --cap-edge: #c9c7bd; --accent: #0a66e8; --accent-ink: #ffffff;
--case: linear-gradient(170deg, #f4f4f1, #d8d9d5); --plate: #e4e5e3;
--warn: #a35a00; --warn-bg: #fdf0dc; --danger: #c62828; --danger-bg: #fde4e4; --ok: #2f7d32; --ok-bg: #e2f3e3;
--s-builtin: #6b7480; --s-neovim: #0f8a7e; --s-config: #d6336c; --s-plugin: #6a48d7;
--h1: #1f6feb; --h2: #0f8a7e; --h3: #d1462f; --h4: #2f8a3a; --h5: #7c4ddb; --h6: #b7791f; --h7: #4b55d6;
--h8: #c03aa8; --h9: #0e7fa0; --h10: #6f7d17; --h11: #d6336c; --h12: #8a5a2b; --h13: #6b7280;
}
.board[data-kb="light"] .case { box-shadow: inset 0 1px 0 #fff, 0 12px 30px -16px rgba(0,0,0,.45); }
.board[data-kb="light"] .kb { box-shadow: inset 0 1px 3px rgba(0,0,0,.12); }
.keyinfo { min-height: 7em; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; display: grid; gap: 10px; font-size: 0.95rem; }
.ki-head { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
.ki-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.ki-list li { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid var(--line); }
.ki-list li:first-child { border-top: 0; padding-top: 0; }
.ki-list .top { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; }
.ki-list .was { text-decoration: line-through; color: var(--muted); }
.kids { display: flex; flex-wrap: wrap; gap: 6px; }
.kid { display: inline-flex; gap: 6px; align-items: center; border: 1px solid var(--line); border-radius: 8px; padding: 3px 8px 3px 4px; font-size: 0.82rem; background: var(--bg); }
.kid .keys kbd { font-size: 0.72rem; }
/* ---- Leader tree -------------------------------------------------------- */
.ltree { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 14px; align-items: start; }
.lcard { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; display: grid; grid-template-columns: max-content minmax(0, 1fr) auto; gap: 6px 10px; border-top: 4px solid var(--cat, var(--line)); min-width: 0; align-content: start; }
.lcard > header, .lcard > h4 { grid-column: 1 / -1; }
.lcard header { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 2px; }
.lcard header .keys kbd { font-size: 0.8rem; }
.lcard h3 { font-size: 1rem; }
.lcard h4 .keys { text-transform: none; }
.lcard h4 { margin: 6px 0 0; font: 600 0.72rem var(--mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); display: flex; gap: 6px; align-items: center; }
.litem { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; font-size: 0.9rem; padding: 1px 0; }
.litem .keys kbd { font-size: 0.72rem; }
.litem .ms { display: flex; gap: 3px; }
/* ---- Composer ----------------------------------------------------------- */
.composer { display: grid; gap: 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.crow { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 8px 14px; align-items: start; }
.crow > .lbl { font: 600 0.75rem var(--mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); padding-top: 6px; }
.crow .chips { flex-wrap: wrap; overflow: visible; }
.crow .chip { font-family: var(--mono); }
.crow .chip.mini { border-style: dashed; }
.crow .chip small { font-family: var(--sans); opacity: 0.75; margin-left: 5px; }
.crow input[type="number"] { width: 5em; font: 500 1rem var(--mono); padding: 5px 8px; border-radius: 8px; border: 1px solid var(--line); background: var(--cap); color: var(--ink); }
.cout { display: grid; gap: 8px; padding: 16px; border-radius: 10px; background: var(--bg); border: 1px solid var(--line); }
.cout .keys kbd { font-size: 1.15rem; }
.cout .keys .ph { font-size: 1rem; }
.cout p.sent { font-size: 1.1rem; }
.cout p.sent b { color: var(--accent); }
@media (max-width: 560px) { .crow { grid-template-columns: minmax(0, 1fr); } .crow > .lbl { padding-top: 0; } }
/* ---- Plugin windows / commands ------------------------------------------ */
.wtools { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; }
.wtools .search { flex: 1 1 280px; }
.wtools .search input { font-size: 1rem; padding: 8px 0; }
.wtabs .tab span { font: 500 0.75rem var(--mono); color: var(--muted); font-variant-numeric: tabular-nums; }
.wsubs { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.wsubs .chip { font-size: 0.8rem; padding: 3px 10px; }
.wpanel { display: grid; gap: 22px; }
.wgroup { display: grid; gap: 6px; scroll-margin-top: 16px; }
.wgroup h3 { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.wgroup > .lede { font-size: 0.9rem; }
.wlist { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr)); gap: 0 28px; }
.witem { display: grid; grid-template-columns: 7.2em minmax(0, 1fr); gap: 10px; padding: 6px 0; border-top: 1px solid var(--line); font-size: 0.9rem; align-items: start; }
.witem .keys kbd { font-size: 0.72rem; }
.witem .act { display: grid; gap: 2px; min-width: 0; }
.witem .chain { font: 500 0.75rem var(--mono); color: var(--muted); overflow-wrap: anywhere; }
.witem .ms { display: inline-flex; gap: 3px; margin-left: 6px; vertical-align: 1px; }
.witem .rn { font-size: 0.82rem; }
.clist { display: grid; gap: 0; }
.crow2 { display: grid; grid-template-columns: minmax(120px, 220px) minmax(0, 1fr); gap: 4px 16px; padding: 8px 0; border-top: 1px solid var(--line); font-size: 0.92rem; }
.crow2 .cn { font: 700 0.92rem var(--mono); overflow-wrap: anywhere; }
.crow2 .meta { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin-top: 3px; font: 500 0.72rem var(--mono); color: var(--muted); }
@media (max-width: 560px) { .crow2, .witem { grid-template-columns: minmax(0, 1fr); } }
footer { font-size: 0.85rem; color: var(--muted); }
footer code { font-size: 0.8rem; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { transition: none !important; animation: none !important; }
}
</style>
</head>
<body>
<a class="skip" href="#find">Skip to the key finder</a>
<div class="wrap">
<header>
<div class="eyebrow" id="eyebrow">Rootiest Neovim</div>
<div class="titlebar">
<h1>Vim keys &amp; keymaps</h1>
<div class="seg" id="theme" role="group" aria-label="Colour theme">
<button type="button" data-theme-set="auto" aria-pressed="true">Auto</button>
<button type="button" data-theme-set="light" aria-pressed="false">Light</button>
<button type="button" data-theme-set="dark" aria-pressed="false">Dark</button>
</div>
</div>
<nav aria-label="Sections">
<a href="#different">What's different</a><a href="#keyboard">Keyboard</a><a href="#leader">Leader keys</a>
<a href="#find">Find any key</a><a href="#composer">Text objects</a><a href="#windows">Plugin windows</a><a href="#commands">Commands</a>
</nav>
<ul class="stats" id="stats"></ul>
</header>
<section id="different" class="stack" aria-labelledby="h-different">
<h2 id="h-different">What's different here</h2>
<p class="lede">How this config changes stock Vim. Cards first, then lists worked out from the live keymaps.</p>
<div class="chips" id="notechips" role="group" aria-label="Filter cards by kind"></div>
<div class="notes" id="notecards"></div>
<button type="button" class="btn big" id="notemore" hidden></button>
<div class="difflists" id="difflists"></div>
</section>
<section id="keyboard" class="layers" aria-labelledby="h-keyboard">
<h2 id="h-keyboard">Keyboard</h2>
<p class="lede">What every key does in each mode or after each prefix. Hover or focus a key for details; click it to look it up in the finder. Click <kbd>Shift</kbd> or <kbd>Ctrl</kbd> on the board to hold them.</p>
<div class="tabs" id="kbtabs" role="tablist" aria-label="Keyboard layers"></div>
<div class="layer-meta">
<p class="grow" id="layermeta"></p>
<div class="seg" id="aiswitch" role="group" aria-label="Text object scope" hidden>
<button type="button" data-ai="i" aria-pressed="true">i · inside</button>
<button type="button" data-ai="a" aria-pressed="false">a · around</button>
</div>
</div>
<div class="kbctl">
<div class="seg" id="kblayout" role="group" aria-label="Keyboard layout"></div>
<div class="seg" id="kbtheme" role="group" aria-label="Keyboard colourway">
<button type="button" data-kbt="dark" aria-pressed="true">Dark</button>
<button type="button" data-kbt="light" aria-pressed="false">Light</button>
</div>
</div>
<div class="legend" id="kblegend"></div>
<div class="board" id="board" data-kb="dark" data-board="standard"><div class="case"><div class="kb" id="kb" role="group" aria-label="Keyboard"></div></div></div>
<div class="keyinfo" id="keyinfo" aria-live="polite"></div>
</section>
<section id="leader" class="stack" aria-labelledby="h-leader">
<h2 id="h-leader">Leader keys</h2>
<p class="lede" id="leaderintro"></p>
<div class="ltree" id="ltree"></div>
</section>
<section id="find" class="stack" aria-labelledby="h-find">
<h2 id="h-find">Find any key</h2>
<p class="lede">Every built-in key, Neovim default, plugin map and your own maps in one list. Search by keys (<code>ctrl-w</code>, <code>&lt;C-w&gt;</code>, <code>^w</code>, <code>space f</code>, <code>leader f</code>) or by words. Start with <code>:</code> to search Ex commands.</p>
<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" autocapitalize="off" placeholder="Keys or words — e.g. gU, ctrl-w v, leader f, delete word, :sort" aria-label="Search keys and keymaps" aria-controls="results">
<kbd title="Press / to focus">/</kbd>
</label>
<span class="count" id="count" aria-live="polite"></span>
</div>
<div class="chips" id="modechips" role="group" aria-label="Filter by mode"></div>
</div>
<div class="filters">
<div class="opts">
<div class="seg" id="scope" role="group" aria-label="Which keys to list">
<button type="button" data-scope="starter" aria-pressed="true" title="Essentials plus everything your config adds or changes. Typing a search looks through everything.">Essentials + my changes</button>
<button type="button" data-scope="ess" aria-pressed="false">Essentials</button>
<button type="button" data-scope="chg" aria-pressed="false">Changed by my config</button>
<button type="button" data-scope="all" aria-pressed="false">Everything</button>
</div>
<span class="scopehint" id="scopehint"></span>
<button type="button" class="btn" id="reset">Reset filters</button>
</div>
<div class="chips wrapwide" id="srcchips" role="group" aria-label="Filter by source"></div>
<div class="chips wrapwide" id="catchips" role="group" aria-label="Filter by category"></div>
</div>
<div class="results" id="results"></div>
<button type="button" class="btn big" id="more" hidden>Show more</button>
</section>
<section id="composer" class="stack" aria-labelledby="h-composer">
<h2 id="h-composer">Text-object composer</h2>
<p class="lede" id="composerintro">Pick an operator and a text object to see the keys and what they do.</p>
<div class="composer" id="comp"></div>
</section>
<section id="windows" class="stack" aria-labelledby="h-windows">
<h2 id="h-windows">Inside plugin windows</h2>
<p class="lede">Keys that only work inside a plugin's own window, menu or buffer, from your live config. Pick a group, then jump to one window with the chips below it, or search them all.</p>
<div class="wtools">
<label class="search" for="wq">
<svg width="16" height="16" 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="wq" type="search" autocomplete="off" spellcheck="false" autocapitalize="off" placeholder="Search every plugin window — e.g. ctrl-q, split, stage" aria-label="Search keys in plugin windows">
</label>
<span class="count" id="wcount" aria-live="polite"></span>
</div>
<div class="tabs wtabs" id="wtabs" role="tablist" aria-label="Plugin window groups"></div>
<div class="wsubs" id="wsubs" role="group" aria-label="Windows in this group"></div>
<div class="wpanel" id="wpanel" role="tabpanel"></div>
</section>
<section id="commands" class="stack" aria-labelledby="h-commands">
<h2 id="h-commands">Commands</h2>
<p class="lede">User and plugin <code>:commands</code> defined by this config. Built-in Ex commands are in the finder (choose <b>Ex commands</b> or type <code>:</code>).</p>
<div class="searchrow">
<label class="search" for="cq">
<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="cq" type="search" autocomplete="off" spellcheck="false" autocapitalize="off" placeholder="Filter commands — e.g. git, box, Mason" aria-label="Filter commands">
</label>
<span class="count" id="ccount" aria-live="polite"></span>
</div>
<div class="chips wrapwide" id="cchips" role="group" aria-label="Filter commands by source"></div>
<div class="clist" id="clist"></div>
</section>
<footer>Generated from <code>index.txt</code>, your live config and <code>curated/*.json</code> by <code>gen_cheatsheet.py</code>.</footer>
</div>
<div class="toast" id="toast" role="status" hidden></div>
<script>
"use strict";
const DATA = /*DATA*/null;
// ---- Basics -------------------------------------------------------------------
// Every DATA section is optional: missing pieces render as empty, never crash.
const D = DATA && typeof DATA === "object" ? DATA : {};
const arr = (x) => (Array.isArray(x) ? x : []);
const obj = (x) => (x && typeof x === "object" && !Array.isArray(x) ? x : {});
const META = obj(D.meta);
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const stripTags = (h) => String(h ?? "").replace(/<[^>]*>/g, " ");
const TIMEOUT = META.timeoutlen ?? obj(META.options).timeoutlen ?? 1000;
const REDUCE = window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches;
const scrollToEl = (el) => el && el.scrollIntoView({ behavior: REDUCE ? "auto" : "smooth", block: "start" });
// ---- Theme ----------------------------------------------------------------------
const THEME_KEY = "nvim-cheatsheet-theme";
function setTheme(t, save) {
const root = document.documentElement;
if (t === "light" || t === "dark") root.setAttribute("data-theme", t);
else { root.removeAttribute("data-theme"); t = "auto"; }
for (const b of document.querySelectorAll("[data-theme-set]")) b.setAttribute("aria-pressed", String(b.dataset.themeSet === t));
if (save) { try { localStorage.setItem(THEME_KEY, t); } catch (e) { /* storage blocked: theme just isn't remembered */ } }
}
{
let saved = "auto";
try { saved = localStorage.getItem(THEME_KEY) || "auto"; } catch (e) { /* ignore */ }
setTheme(saved, false);
}
$("theme").addEventListener("click", (e) => {
const b = e.target.closest("[data-theme-set]");
if (b) setTheme(b.dataset.themeSet, true);
});
// ---- Toast & clipboard -----------------------------------------------------------
let toastTimer;
function toast(msg) {
$("toast").textContent = msg;
$("toast").hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => ($("toast").hidden = true), 1800);
}
async function copy(text) {
try { await navigator.clipboard.writeText(text); toast(`Copied ${text}`); }
catch (e) {
// Older browsers / file:// without clipboard permission: fall back to a hidden textarea.
const ta = document.createElement("textarea");
ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
let ok = false;
try { ok = document.execCommand("copy"); } catch (err) { ok = false; }
ta.remove();
toast(ok ? `Copied ${text}` : "Copy blocked by the browser — select the text instead");
}
}
document.addEventListener("click", (e) => {
const b = e.target.closest("[data-copy]");
if (b) copy(b.dataset.copy);
});
// ---- Key notation ------------------------------------------------------------------
// Vim notation is split into tokens: <C-w>, <Space>, {motion}, ["x], or one character.
const TOKEN_RE = /\["x\]|\{[^}\s]+\}|<(?:[A-Za-z0-9]+-)*(?:\{[^}]+\}|[A-Za-z0-9]+|[^>\s])>|[\s\S]/g;
const tokenize = (k) => String(k ?? "").match(TOKEN_RE) || [];
const isPh = (t) => t === '["x]' || (t.length > 2 && t[0] === "{" && t[t.length - 1] === "}");
const LEADER = typeof META.leader === "string" && META.leader ? META.leader : "\\";
const LOCAL = typeof META.localleader === "string" && META.localleader ? META.localleader : "\\";
const charTok = (c) => (c === " " ? "<Space>" : c);
const LEADER_TOK = charTok(LEADER);
const LOCAL_TOK = charTok(LOCAL);
const NAMED = {
cr: "CR", return: "CR", enter: "CR", esc: "Esc", escape: "Esc", tab: "Tab", bs: "BS", backspace: "BS", space: "Space",
nl: "NL", del: "Del", delete: "Del", insert: "Insert", ins: "Insert", up: "Up", down: "Down", left: "Left", right: "Right",
home: "Home", end: "End", pageup: "PageUp", pagedown: "PageDown", pgup: "PageUp", pgdn: "PageDown", help: "Help",
undo: "Undo", nop: "Nop", plug: "Plug", ignore: "Ignore", cmd: "Cmd", sid: "SID", snr: "SNR",
};
const MOD_ALIAS = { c: "C", ctrl: "C", control: "C", s: "S", shift: "S", m: "M", a: "M", alt: "M", meta: "M", d: "D", cmd: "D", super: "D", t: "T" };
/** Canonical form of one token, matching the generator's keytrans style: <C-W>, <M-p>, <CR>, <Space>. */
function canonTok(t) {
if (t.length < 3 || t[0] !== "<" || t[t.length - 1] !== ">") return t;
const inner = t.slice(1, -1);
const low = inner.toLowerCase();
if (low === "leader") return LEADER_TOK;
if (low === "localleader") return LOCAL_TOK;
if (low === "lt") return "<";
if (low === "bar") return "|";
if (low === "bslash") return "\\";
const parts = inner.split("-");
let key = parts.pop();
if (key === "") key = "-";
const mods = parts.filter(Boolean).map((m) => MOD_ALIAS[m.toLowerCase()] || m.toUpperCase());
if (key.length > 1 && key[0] !== "{") {
key = NAMED[key.toLowerCase()] || (/^f\d+$/i.test(key) ? key.toUpperCase() : key[0].toUpperCase() + key.slice(1));
} else if (mods.length && mods.every((m) => m === "C") && /^[a-z]$/i.test(key)) {
key = key.toUpperCase();
}
if (!mods.length) return key.length === 1 ? key : `<${key}>`;
return `<${mods.join("-")}-${key}>`;
}
const canonKeys = (k) => tokenize(k).map(canonTok);
/** Split a canonical token into modifiers and key ("<C-S-Tab>" → C,S + Tab). */
function splitTok(t) {
if (t.length < 3 || t[0] !== "<" || t[t.length - 1] !== ">") return { mods: [], key: t, plain: true };
const parts = t.slice(1, -1).split("-");
let key = parts.pop();
if (key === "") key = "-";
return { mods: parts.filter(Boolean), key, plain: false };
}
const NICE = {
CR: "Enter", Esc: "Esc", Tab: "Tab", BS: "Bksp", Space: "Space", NL: "NL", Del: "Del", Insert: "Ins", Up: "↑", Down: "↓",
Left: "←", Right: "→", Home: "Home", End: "End", PageUp: "PgUp", PageDown: "PgDn", Bar: "|", Bslash: "\\",
};
const MODN = { C: "Ctrl", S: "Shift", M: "Alt", A: "Alt", D: "Cmd", T: "Meta" };
const WORDN = { CR: "enter", BS: "backspace", Up: "up", Down: "down", Left: "left", Right: "right", Space: "space", Esc: "esc" };
/** Words for a token, so "ctrl-w", "space" and "enter" find keys in the search haystack. */
function humanTok(t) {
const { mods, key, plain } = splitTok(t);
if (plain) return t === " " ? "space" : t;
const k = (WORDN[key] || key).toLowerCase();
return mods.length ? [...mods.map((m) => (MODN[m] || m).toLowerCase()), k].join("-") + ` ^${k}` : k;
}
/** Short text form of tokens for keycap glyphs and chips ("^W h", "Space f"). */
function glyphTok(t) {
const { mods, key, plain } = splitTok(t);
if (plain) return t === " " ? "Space" : t;
const k = NICE[key] || key;
if (!mods.length) return k;
if (mods.length === 1 && mods[0] === "C") return "^" + k;
// Compact for keycaps: ⇧ for Shift, ^ for Ctrl ("⇧↑", "^⇧Tab", "Alt+x").
return mods.map((m) => (m === "S" ? "⇧" : m === "C" ? "^" : (MODN[m] || m) + "+")).join("") + k;
}
// ---- Source & category styling ------------------------------------------------------
const RUNTIME_SRC = new Set(["neovim", "ftplugin", "matchit"]);
const srcKind = (s) => (s === "builtin" ? "builtin" : RUNTIME_SRC.has(s) ? "neovim" : s === "config" ? "config" : "plugin");
const SRC_LABEL = { builtin: "Built-in", neovim: "Neovim default", ftplugin: "Runtime ftplugin", matchit: "matchit (runtime)", config: "Your config" };
const srcLabel = (s) => SRC_LABEL[s] || s;
const srcBadge = (s) => `<span class="src ${srcKind(s)}">${esc(srcLabel(s))}</span>`;
const CAT_ORDER = ["Motion", "Text object", "Operator", "Edit", "Insert", "Visual", "Search", "Mark & jump", "Register & macro",
"Undo & repeat", "Scroll & view", "Window", "Buffer & tab", "Fold", "Quickfix & lists", "LSP & diagnostics", "Git",
"Find & pick", "Surround", "Pairs", "Comment", "Completion & AI", "Session", "Terminal", "Command line", "Files", "Tags",
"Diff", "Spell", "Notes", "UI & toggles", "Help", "Misc"];
const CAT_HUE = {
"Motion": 1, "Text object": 2, "Operator": 3, "Edit": 3, "Insert": 4, "Visual": 5, "Search": 6, "Mark & jump": 7,
"Register & macro": 8, "Undo & repeat": 8, "Scroll & view": 9, "Window": 10, "Buffer & tab": 10, "Fold": 9,
"Quickfix & lists": 12, "LSP & diagnostics": 7, "Git": 11, "Find & pick": 6, "Surround": 2, "Pairs": 2, "Comment": 9,
"Completion & AI": 4, "Session": 10, "Terminal": 12, "Command line": 12, "Files": 12, "Tags": 7, "Diff": 11, "Spell": 5,
"Notes": 5, "UI & toggles": 5, "Help": 13, "Misc": 13,
};
const catVar = (c) => `var(--h${CAT_HUE[c] || 13})`;
const catRank = (c) => { const i = CAT_ORDER.indexOf(c); return i < 0 ? CAT_ORDER.length : i; };
const MODE_INFO = {
n: ["N", "Normal"], x: ["V", "Visual"], v: ["V", "Visual & Select"], o: ["O", "Operator-pending"], i: ["I", "Insert"],
c: ["C", "Command-line"], t: ["T", "Terminal"], s: ["S", "Select"], ":": [":", "Ex command"], l: ["L", "Lang-arg"],
};
const modeBadges = (ms) => arr(ms).map((m) => `<span class="badge m" title="${esc((MODE_INFO[m] || [m, m])[1])} mode">${esc((MODE_INFO[m] || [m])[0])}</span>`).join("");
const modeNames = (ms) => arr(ms).map((m) => (MODE_INFO[m] || [m, m])[1]).join(", ");
// neovim.io serves each help file at /doc/user/<name>/ (index.txt is "vimindex") and its tag ids are
// percent-encoded like encodeURIComponent plus "!" → %21 (checked against the live site: 1251 links).
const helpURL = (file, tag) => {
const name = String(file).replace(/\.txt$/, "");
return `https://neovim.io/doc/user/${encodeURIComponent(name === "index" ? "vimindex" : name)}/#${encodeURIComponent(tag).replace(/!/g, "%21")}`;
};
// ---- Entries ----------------------------------------------------------------------------
// One normalised object per built-in, live map, curated entry and command.
// tok: canonical tokens btok: without a leading register/count core: also without trailing {placeholders}
function mk(e, kind, idx) {
e = obj(e);
const raw = String(e.k ?? "");
const ex = kind === "cmd" || !!e.ex;
const x = { kind, idx, ref: e, raw, ex };
x.s = kind === "builtin" ? "builtin" : String(e.s || "other");
x.sk = srcKind(x.s);
x.ld = !!e.ld || /^<leader>/i.test(raw);
x.lld = /^<localleader>/i.test(raw);
x.tok = ex ? [raw] : canonKeys(raw);
let b = 0;
while (!ex && b < x.tok.length && isPh(x.tok[b])) b++;
x.btok = x.tok.slice(b);
let end = x.btok.length;
while (!ex && end > 0 && isPh(x.btok[end - 1])) end--;
x.core = x.btok.slice(0, end);
x.kb = x.core.join("");
x.kbl = x.kb.toLowerCase();
x.kf = x.btok.join("");
// Keys up to the first placeholder: "/" for "/{pattern}<CR>".
{ const p = x.btok.findIndex(isPh); x.kh = p < 0 ? x.kb : x.btok.slice(0, p).join(""); }
x.m = ex ? [":"] : (arr(e.m).length ? arr(e.m).map(String) : (typeof e.m === "string" ? [e.m] : ["n"]));
x.d = String(e.d ?? "");
x.cat = e.cat || (ex ? "Command line" : "Misc");
x.ctx = e.ctx || null;
x.n = e.n ? String(e.n) : "";
x.e = !!e.e;
x.sl = e.sl ? String(e.sl) : "";
// Help: built-ins carry tag + file; live/curated carry a tag and (when it's a runtime tag) "hf".
x.tag = e.t || (kind === "curated" ? e.h : null) || null;
x.hfile = kind === "builtin" ? e.h || null : e.hf || null;
if (ex) {
// ":s[ubstitute]" → abbreviation "s", full name "substitute".
const m = raw.replace(/^:/, "").match(/^([^[]*)(?:\[([^\]]*)\])?(.*)$/) || ["", raw, "", ""];
x.exAbbr = m[1];
x.exFull = m[1] + (m[2] || "") + m[3];
}
x.changed = kind === "builtin" ? !!(arr(e.ov).length || arr(e.wait).length) : kind === "live" ? !!(arr(e.rep).length || arr(e.sh).length) : false;
return x;
}
function finishHay(x) {
const e = x.ref;
x.hay = [x.raw, x.kf, x.ex ? "" : x.tok.map(humanTok).join(" "), x.ld ? "leader" : "", x.d, x.cat, srcLabel(x.s), x.s, x.ctx,
x.tag, stripTags(x.n), e.rhs, e.sec, e.f].filter(Boolean).join(" ").toLowerCase();
x.dl = x.d.toLowerCase();
x.words = new Set(x.hay.split(/[^a-z0-9_]+/).filter(Boolean));
}
// index.txt lists some keys twice (the Visual-mode table repeats every text object, e.g. "as" with tag
// v_as): fold an entry with the same keys and help tag into the first one, keeping its index as an alias.
const BUILT_ALL = arr(D.builtins).map((e, i) => mk(e, "builtin", i));
{
const first = new Map();
for (const x of BUILT_ALL) {
if (x.ex || !x.tag) continue;
const key = `${x.raw}\u0000${x.tag}`;
const p = first.get(key);
if (!p) { first.set(key, x); continue; }
x.dup = p;
p.m = [...new Set([...p.m, ...x.m])];
for (const f of ["ov", "wait"]) if (arr(x.ref[f]).length) p.ref[f] = [...arr(p.ref[f]), ...arr(x.ref[f])];
if (!p.n && x.n) p.n = x.n;
p.e = p.e || x.e;
p.changed = !!(arr(p.ref.ov).length || arr(p.ref.wait).length);
}
}
// "{char1}<BS>{char2}" (digraphs) starts with a typed character, so a map of <BS> doesn't replace it;
// only a register or count may come before the keys a mapping can take over.
for (const x of BUILT_ALL) {
if (x.ex || !x.tok.length || !isPh(x.tok[0]) || /^(\["x\]|\{count\})$/.test(x.tok[0])) continue;
x.noOv = true;
x.ref.ov = []; x.ref.wait = [];
x.changed = false;
}
const BUILT = BUILT_ALL.filter((x) => !x.dup);
const LIVE = arr(D.live).map((e, i) => mk(e, "live", i));
for (const x of LIVE) {
const r = arr(x.ref.rep).filter((o) => !(BUILT_ALL[o.b] && BUILT_ALL[o.b].noOv));
if (r.length !== arr(x.ref.rep).length) { x.ref.rep = r; x.changed = !!(r.length || arr(x.ref.sh).length); }
}
const CUR = arr(D.curated).filter((e) => e && e.k).map((e, i) => mk(e, "curated", i));
const CMDS = arr(D.commands).filter((e) => e && e.k).map((e, i) => mk(e, "cmd", i));
// mini.ai's own text objects (verified in mini.ai/lua/mini/ai.lua, H.builtin_textobjects): used only
// when the curated data doesn't describe them, so the keyboard and composer still know about them.
const MINI_AI = LIVE.some((x) => x.s === "mini.ai" && x.kb === "i" && x.m.includes("o"));
// mini.ai's next/last suffix keys as configured (the config moves "next" to aN/iN so Neovim's an/in stay free).
const aiSuffix = (word, dflt) => {
const e = LIVE.find((x) => x.s === "mini.ai" && /^i.$/.test(x.kb) && new RegExp(word, "i").test(x.d));
return e ? e.kb.slice(1) : dflt;
};
const AI_NEXT = aiSuffix("next", "n"), AI_LAST = aiSuffix("last", "l");
const MINI_AI_IDS = {
a: "argument", f: "function call", q: "any quote \" ' `", b: "any bracket ( [ {", t: "tag", "?": "prompted left/right edges",
// "(" "[" "{" "<": inner part trims edge whitespace; ")" "]" "}" ">" keep it (mini.ai's builtin table).
"(": "( ) · i trims spaces", ")": "( ) block", "[": "[ ] · i trims spaces", "]": "[ ] block", "{": "{ } · i trims spaces", "}": "{ } block",
"<": "< > · i trims spaces", ">": "< > block", '"': "double-quoted string", "'": "single-quoted string", "`": "backtick string",
};
if (MINI_AI) {
const have = new Set(CUR.filter((x) => x.s === "mini.ai").map((x) => x.kb));
for (const id of ["a", "f", "q", "?"]) {
for (const ai of ["i", "a"]) {
if (have.has(ai + id)) continue;
const x = mk({ k: ai + id, m: ["o", "x"], s: "mini.ai", cat: "Text object", h: "MiniAi-builtin-textobjects",
d: `${ai === "i" ? "Inside" : "Around"} ${MINI_AI_IDS[id]} (mini.ai)` }, "curated", CUR.length);
x.synthetic = true;
CUR.push(x);
}
}
}
const ALL = [...BUILT, ...LIVE, ...CUR, ...CMDS];
ALL.forEach((x, i) => { x.id = i; finishHay(x); });
const liveAt = (j) => LIVE[j];
const builtAt = (i) => { const x = BUILT_ALL[i]; return x ? x.dup || x : undefined; };
// A printable key mapped in Select mode: snippet placeholders are Select mode, so typing that key
// over a placeholder runs the map instead of replacing the placeholder text.
const selectTrap = (x) => x.kind === "live" && x.m.includes("s") && !x.ctx && x.tok.length > 0 && (x.tok[0].length === 1 || x.tok[0] === "<Space>");
// Leader groups: "<leader>f" → { name, cat }
const GROUPS = new Map();
for (const [k, v] of Object.entries(obj(D.groups))) {
const g = obj(v);
GROUPS.set(canonKeys(k).join(""), { k, name: String(g.name || ""), cat: g.cat || null });
}
// ---- Header -----------------------------------------------------------------------------
{
const ver = String(META.version || "").replace(/\+.*$/, "");
$("eyebrow").textContent = `Rootiest Neovim · NVIM ${ver || "?"} · generated ${META.date || "?"}`;
const builtinKeys = BUILT.filter((x) => !x.ex).length;
const exCount = BUILT.filter((x) => x.ex).length;
const mine = LIVE.filter((x) => x.sk === "config").length;
const plug = LIVE.filter((x) => x.sk === "plugin").length;
const nv = LIVE.filter((x) => x.sk === "neovim").length;
const changed = BUILT.filter((x) => arr(x.ref.ov).length).length;
const leaderCap = LEADER === " " ? `<kbd>Space</kbd>` : `<kbd>${esc(LEADER)}</kbd>`;
const localCap = LOCAL === " " ? `<kbd>Space</kbd>` : `<kbd>${esc(LOCAL)}</kbd>`;
const st = [
[leaderCap, "Leader"], [localCap, "Local leader"],
[builtinKeys.toLocaleString(), `Built-in keys (+ ${exCount.toLocaleString()} Ex commands)`],
[mine.toLocaleString(), "Your maps"], [plug.toLocaleString(), "Plugin maps"], [nv.toLocaleString(), "Neovim default maps"],
[changed.toLocaleString(), "Built-ins changed by the config"],
];
$("stats").innerHTML = st.map(([v, l]) => `<li><b>${v}</b><span>${esc(l)}</span></li>`).join("");
}
// ---- Rendering helpers --------------------------------------------------------------------
/** One token as keycap HTML. The first token of a leader map renders as a distinct Leader cap. */
function capHTML(t, first, x) {
if (t === '["x]') return `<span class="reg" title="Optional register: type &quot; and a register name first">"x</span>`;
if (isPh(t)) return `<span class="ph" title="Placeholder">${esc(t)}</span>`;
if (first && x && x.ld && t === LEADER_TOK) return `<kbd class="lead" title="Leader">${LEADER === " " ? "Space" : esc(LEADER)}<small>leader</small></kbd>`;
if (first && x && x.lld && t === LOCAL_TOK) return `<kbd class="lead" title="Local leader">${LOCAL === " " ? "Space" : esc(LOCAL)}<small>local</small></kbd>`;
const { mods, key, plain } = splitTok(t);
if (plain) return `<kbd>${t === " " ? "Space" : esc(t)}</kbd>`;
const label = NICE[key] || key;
const cap = `<kbd${label.length > 1 ? ' class="named"' : ""} title="${esc(t)}">${esc(label)}</kbd>`;
if (!mods.length) return cap;
return `<span class="combo" title="${esc(t)}">${mods.map((m) => `<kbd class="mod">${esc(MODN[m] || m)}</kbd>`).join("")}${cap}</span>`;
}
function exHTML(raw) {
const m = raw.match(/^([^[]*)(\[[^\]]*\])?(.*)$/);
return `<code class="exk">${esc(m[1])}${m[2] ? `<span class="opt">${esc(m[2])}</span>` : ""}${esc(m[3])}</code>`;
}
function keysHTML(x, toks) {
if (x && x.ex) return `<span class="keys">${exHTML(x.raw)}</span>`;
// Curated alternatives written as "K / gx": one cap group per alternative.
if (!toks && x.raw && / \/ /.test(x.raw)) {
const alts = x.raw.split(/ \/ /).map((s) => s.trim()).filter(Boolean);
return `<span class="keys" aria-label="${esc(x.raw)}">${alts.map((a) => canonKeys(a).map((t, i) => capHTML(t, i === 0, x)).join("")).join(`<span class="alt" aria-hidden="true">/</span>`)}</span>`;
}
toks = toks || x.tok;
const aria = esc(toks.join(""));
return `<span class="keys" aria-label="${aria}">${toks.map((t, i) => capHTML(t, i === 0, x)).join("")}</span>`;
}
const tokCaps = (toks, opts = {}) => keysHTML({ tok: toks, ld: !!opts.ld, lld: false }, toks);
function fileLine(x) {
const e = x.ref;
if (!e.f) return "";
const path = x.sk === "config" ? e.f : x.sk === "neovim" ? `$VIMRUNTIME/${e.f}` : `${x.s}/${e.f}`;
return `<span title="Defined in">${esc(path)}${e.l ? ":" + esc(e.l) : ""}</span>`;
}
function helpHTML(x) {
if (!x.tag) return "";
const link = x.hfile ? `<a href="${esc(helpURL(x.hfile, x.tag))}" target="_blank" rel="noopener">:h ${esc(x.tag)}</a>` : `<span class="fl">:h ${esc(x.tag)}</span>`;
return `${link} <button type="button" class="copy" data-copy=":h ${esc(x.tag)}" aria-label="Copy :h ${esc(x.tag)}">copy</button>`;
}
const groupBy = (list, f) => { const m = new Map(); for (const v of list) { const k = f(v); if (!m.has(k)) m.set(k, []); m.get(k).push(v); } return m; };
const modeList = (list) => [...new Set(list.map((o) => o.m))];
/** Warning chips: replaced, replaces, unreachable, waits, nowait, Select-mode trap. */
function flagsHTML(x, onlyMode) {
const e = x.ref, out = [];
const fit = (o) => !onlyMode || o.m === onlyMode;
if (x.kind === "builtin") {
for (const [j, list] of groupBy(arr(e.ov).filter(fit), (o) => o.by)) {
const L = liveAt(j);
if (!L) continue;
out.push(`<span class="flag over">Replaced${onlyMode ? "" : ` in ${esc(modeNames(modeList(list)))}`} by <b>${esc(L.d)}</b> · ${esc(srcLabel(L.s))}${L.ctx ? ` (${esc(L.ctx)})` : ""}</span>`);
}
for (const w of arr(e.wait).filter(fit)) {
const ext = arr(w.ext);
out.push(`<span class="flag" title="A longer mapping starts with this key, so Neovim waits 'timeoutlen' before running it">Waits ${esc(TIMEOUT)} ms${onlyMode ? "" : ` in ${esc(modeNames([w.m]))}`}: ${ext.map((k) => `<code>${esc(k)}</code>`).join(" ")}${w.n > ext.length ? ` +${w.n - ext.length} more` : ""}</span>`);
}
} else if (x.kind === "live") {
const seen = new Set();
for (const [i, list] of groupBy(arr(e.rep).filter(fit), (o) => o.b)) {
const B = builtAt(i);
if (!B || seen.has(B.d)) continue;
seen.add(B.d);
out.push(`<span class="flag rep">Replaces built-in${onlyMode ? "" : ` (${esc(modeList(list).join(""))})`}: <s>${esc(B.d)}</s></span>`);
}
for (const s of arr(e.sh).filter(fit)) {
const L = liveAt(s.by);
if (L) out.push(`<span class="flag dead">Unreachable: <code>${esc(L.raw)}</code> (${esc(L.d)}) is <code>nowait</code> and fires first</span>`);
}
if (e.nw) out.push(`<span class="flag info">nowait</span>`);
if (selectTrap(x)) out.push(`<span class="flag">Also in Select mode: typing it over a snippet placeholder runs this map</span>`);
}
return out.join("");
}
// ---- Short labels for keycaps --------------------------------------------------------------
// entry.sl wins; then these by help tag (meanings from index.txt); else a label derived from the description.
const SL = {
"h": "left", "j": "down", "k": "up", "l": "right", "w": "word →", "W": "WORD →", "b": "← word", "B": "← WORD", "e": "word end",
"E": "WORD end", "0": "line start", "^": "first char", "$": "line end", "gg": "first line", "G": "last line",
"f": "find →", "F": "← find", "t": "till →", "T": "← till", ";": "repeat f/t", ",": "f/t back", "n": "next match",
"N": "prev match", "star": "word →", "#": "← word", "%": "match pair", "{": "← para", "}": "para →", "(": "← sentence",
")": "sentence →", "H": "screen top", "M": "screen mid", "L": "screen bottom", "i": "insert", "a": "append",
"I": "insert line", "A": "append line", "o": "open below", "O": "open above", "x": "delete char", "X": "delete ←",
"d": "delete", "c": "change", "y": "yank", "p": "put after", "P": "put before", "u": "undo", "CTRL-R": "redo",
"U": "undo line", "r": "replace char", "R": "replace mode", "s": "subst char", "S": "subst line", "C": "change to end",
"D": "delete to end", "Y": "yank line", "J": "join", "K": "keyword help", "v": "visual", "V": "visual line",
"CTRL-V": "visual block", ".": "repeat", "q": "record", "@": "run macro", "Q": "replay macro", "m": "set mark",
"'": "mark line", "`": "mark", "/": "search →", "?": "← search", ":": "cmdline", "~": "toggle case", ">": "indent",
"<": "dedent", "=": "reindent", "!": "filter", "&": "repeat :s", "quote": "register", "g": "g prefix", "z": "z prefix",
"[": "[ prefix", "]": "] prefix", "bar": "column", "_": "first char ↓", "-": "↑ line start", "+": "↓ line start",
"<CR>": "↓ line start", "count": "count", "<Space>": "right", "CTRL-A": "increment", "CTRL-X": "decrement",
"CTRL-O": "jump back", "<Tab>": "jump fwd", "CTRL-I": "jump fwd", "CTRL-D": "½ page ↓", "CTRL-U": "½ page ↑",
"CTRL-F": "page ↓", "CTRL-B": "page ↑", "CTRL-E": "scroll ↓", "CTRL-Y": "scroll ↑", "CTRL-G": "file info",
"CTRL-L": "redraw", "CTRL-]": "tag jump", "CTRL-T": "tag back", "CTRL-^": "alt file", "CTRL-W": "window…",
"CTRL-Z": "suspend", "CTRL-C": "interrupt", "CTRL-N": "down", "CTRL-P": "up", "CTRL-H": "left", "CTRL-J": "down",
"CTRL-M": "↓ line start", "<BS>": "cursor left", "ZZ": "save & close", "ZQ": "quit no save", "<Esc>": "cancel",
"<Del>": "delete char", "<Insert>": "insert", "<Home>": "line start", "<End>": "line end", "<PageUp>": "page ↑",
"<PageDown>": "page ↓", "<Up>": "up", "<Down>": "down", "<Left>": "left", "<Right>": "right", "<F1>": "help", "<Help>": "help",
"<S-Up>": "page ↑", "<S-Down>": "page ↓", "<S-Left>": "← word", "<S-Right>": "word →", "<C-Left>": "← word",
"<C-Right>": "word →", "<C-Home>": "first line", "<C-End>": "last line", "<S-CR>": "page ↓",
// g…
"g~": "toggle case", "gu": "lowercase", "gU": "uppercase", "gq": "format", "gw": "format, stay", "gJ": "join raw",
"gi": "insert at last", "gI": "insert col 1", "gv": "reselect", "gn": "select match", "gN": "← select match",
"gp": "put after+", "gP": "put before+", "g;": "older change", "g,": "newer change", "gf": "open file",
"gF": "file:line", "gd": "local def", "gD": "global def", "gx": "open URL", "gstar": "word →*", "g#": "← word*",
"gj": "↓ display", "gk": "↑ display", "g0": "display start", "g$": "display end", "g_": "last char", "gm": "mid screen",
"gM": "mid line", "ge": "← word end", "gE": "← WORD end", "ga": "char code", "g8": "UTF-8 bytes", "g<": "last output",
"g&": "repeat :s all", "g?": "rot13", "gR": "virt replace", "gr": "virt repl char", "gs": "sleep", "gt": "next tab",
"gT": "prev tab", "g<Tab>": "last tab", "go": "byte N", "gh": "select mode", "gH": "select line", "g@": "operatorfunc",
"g+": "newer state", "g-": "older state", "g]": "tselect", "g_CTRL-]": "tjump", "gV": "no reselect", "g^": "display first",
"g<Home>": "display start", "g<End>": "display end", "g'": "mark (no jump)", "g`": "mark (no jump)", "g?g?": "rot13 line",
"g_CTRL-A": "dump memory", "g_CTRL-G": "word count", "g_CTRL-H": "select block", "gQ": "Ex mode", "g<Down>": "↓ display", "g<Up>": "↑ display",
// z…
"zz": "center", "zt": "top", "zb": "bottom", "z<CR>": "top, BOL", "z.": "center, BOL", "z-": "bottom, BOL",
"za": "toggle fold", "zA": "toggle deep", "zc": "close fold", "zC": "close deep", "zo": "open fold", "zO": "open deep",
"zR": "open all", "zM": "close all", "zr": "fold less", "zm": "fold more", "zf": "create fold", "zF": "fold N lines",
"zd": "delete fold", "zD": "delete deep", "zE": "erase folds", "zj": "next fold", "zk": "prev fold", "zv": "show cursor",
"zx": "update folds", "zX": "reapply", "zn": "folds off", "zN": "folds on", "zi": "toggle folds", "zh": "scroll ←",
"zl": "scroll →", "zH": "½ screen ←", "zL": "½ screen →", "zs": "scroll start", "ze": "scroll end", "zg": "good word",
"zG": "good (temp)", "zw": "bad word", "zW": "bad (temp)", "z=": "spell fix", "zug": "undo zg", "zp": "paste block",
"zP": "paste block ←", "zy": "yank block", "z+": "page ↓", "z^": "page ↑", "z<Left>": "scroll ←", "z<Right>": "scroll →",
// [ ]
"[(": "← unmatched (", "])": "unmatched ) →", "[{": "← unmatched {", "]}": "unmatched } →", "[[": "← section",
"]]": "section →", "[]": "← SECTION", "][": "SECTION →", "[m": "← method", "]m": "method →", "[M": "← method end",
"]M": "method end →", "[s": "← misspelled", "]s": "misspelled →", "[c": "← change", "]c": "change →", "[z": "fold start",
"]z": "fold end", "[p": "put above, indent", "]p": "put below, indent", "['": "← mark line", "]'": "mark line →",
"[`": "← mark", "]`": "mark →", "[/": "← comment", "]/": "comment →", "[#": "← #if", "]#": "#endif →",
"[star": "← comment", "]star": "comment →", "[i": "show def", "]i": "show def", "[I": "list matches", "]I": "list matches",
"[d": "show #define", "]d": "show #define", "[D": "list #defines", "]D": "list #defines", "[f": "open file", "]f": "open file",
"[P": "put, indent", "]P": "put, indent", "[_CTRL-I": "jump to match", "]_CTRL-I": "jump to match",
"[_CTRL-D": "jump #define", "]_CTRL-D": "jump #define",
// CTRL-W …
"CTRL-W_s": "split", "CTRL-W_S": "split", "CTRL-W_v": "vsplit", "CTRL-W_n": "new", "CTRL-W_q": "quit", "CTRL-W_c": "close",
"CTRL-W_o": "only", "CTRL-W_w": "next", "CTRL-W_W": "previous", "CTRL-W_p": "last used", "CTRL-W_h": "← window",
"CTRL-W_j": "↓ window", "CTRL-W_k": "↑ window", "CTRL-W_l": "→ window", "CTRL-W_t": "top window", "CTRL-W_b": "bottom window",
"CTRL-W_H": "move far ←", "CTRL-W_J": "move bottom", "CTRL-W_K": "move top", "CTRL-W_L": "move far →", "CTRL-W_r": "rotate ↓",
"CTRL-W_R": "rotate ↑", "CTRL-W_x": "exchange", "CTRL-W_=": "equalize", "CTRL-W_+": "taller", "CTRL-W_-": "shorter",
"CTRL-W_<": "narrower", "CTRL-W_>": "wider", "CTRL-W__": "max height", "CTRL-W_bar": "max width", "CTRL-W_T": "to new tab",
"CTRL-W_f": "split file", "CTRL-W_F": "split file:line", "CTRL-W_]": "split tag", "CTRL-W_^": "split alt",
"CTRL-W_}": "preview tag", "CTRL-W_z": "close preview", "CTRL-W_P": "to preview", "CTRL-W_d": "split def",
"CTRL-W_i": "split decl", "CTRL-W_g": "g… (tabs)", "CTRL-W_gf": "file in tab", "CTRL-W_gF": "file:line tab",
"CTRL-W_gt": "next tab", "CTRL-W_gT": "prev tab", "CTRL-W_e": "", "CTRL-W_<Up>": "↑ window", "CTRL-W_<Down>": "↓ window",
"CTRL-W_<Left>": "← window", "CTRL-W_<Right>": "→ window",
// Visual
"v_o": "other end", "v_O": "other corner", "v_d": "delete", "v_x": "delete", "v_c": "change", "v_s": "change",
"v_y": "yank", "v_p": "replace", "v_P": "replace, keep", "v_r": "replace chars", "v_u": "lowercase", "v_U": "uppercase",
"v_~": "toggle case", "v_>": "indent", "v_<": "dedent", "v_=": "reindent", "v_J": "join", "v_gJ": "join raw",
"v_:": "cmd on range", "v_b_I": "block insert", "v_b_A": "block append", "v_C": "change lines", "v_S": "change lines",
"v_R": "change lines", "v_X": "delete lines", "v_D": "delete lines", "v_Y": "yank lines", "v_v": "charwise",
"v_V": "linewise", "v_CTRL-V": "blockwise", "v_gv": "swap previous", "v_<Esc>": "exit", "v_CTRL-C": "exit",
"v_CTRL-A": "increment", "v_CTRL-X": "decrement", "v_g_CTRL-A": "sequence +", "v_g_CTRL-X": "sequence −", "v_K": "keyword help",
"v_!": "filter", "v_gq": "format", "v_CTRL-G": "select mode", "v_CTRL-]": "tag jump", "v_CTRL-O": "visual once",
// Text objects
"v_iw": "word", "v_aw": "word + space", "v_iW": "WORD", "v_aW": "WORD + space", "v_is": "sentence", "v_as": "sentence +",
"v_ip": "paragraph", "v_ap": "paragraph +", "v_i(": "( ) inside", "v_a(": "( ) block", "v_i)": "( ) inside",
"v_a)": "( ) block", "v_ib": "( ) inside", "v_ab": "( ) block", "v_i[": "[ ] inside", "v_a[": "[ ] block",
"v_i]": "[ ] inside", "v_a]": "[ ] block", "v_i{": "{ } inside", "v_a{": "{ } block", "v_i}": "{ } inside",
"v_a}": "{ } block", "v_iB": "{ } inside", "v_aB": "{ } block", "v_i<": "< > inside", "v_a<": "< > block",
"v_i>": "< > inside", "v_a>": "< > block", "v_it": "tag inside", "v_at": "tag block", "v_iquote": "\" \" inside",
"v_aquote": "\" \" string", "v_i'": "' ' inside", "v_a'": "' ' string", "v_i`": "` ` inside", "v_a`": "` ` string",
// Insert CTRL-…
"i_CTRL-W": "delete word", "i_CTRL-U": "delete line", "i_CTRL-H": "bksp", "i_CTRL-_": "toggle revins", "i_CTRL-T": "indent", "i_CTRL-D": "dedent",
"i_CTRL-R": "insert register", "i_CTRL-O": "one command", "i_CTRL-N": "complete ↓", "i_CTRL-P": "complete ↑",
"i_CTRL-X": "complete…", "i_CTRL-E": "char below", "i_CTRL-Y": "char above", "i_CTRL-A": "insert last",
"i_CTRL-@": "last + exit", "i_CTRL-V": "literal", "i_CTRL-K": "digraph", "i_CTRL-G": "undo/move…", "i_CTRL-]": "abbreviate",
"i_CTRL-C": "quit insert", "i_CTRL-[": "Esc", "i_CTRL-J": "new line", "i_CTRL-M": "new line", "i_CTRL-I": "Tab",
"i_CTRL-Q": "literal", "i_CTRL-^": "toggle lmap", "i_CTRL-Z": "", "i_<Esc>": "Normal mode", "i_<CR>": "new line",
"i_<Tab>": "Tab", "i_<BS>": "backspace", "i_<Del>": "delete →", "i_<Up>": "up", "i_<Down>": "down", "i_<Left>": "left",
"i_<Right>": "right", "i_<Home>": "line start", "i_<End>": "line end", "i_<PageUp>": "page ↑", "i_<PageDown>": "page ↓",
"i_<Insert>": "toggle replace", "i_<F1>": "help", "i_<NL>": "new line", "i_CTRL-B": "", "i_CTRL-L": "",
"i_<C-Left>": "← word", "i_<C-Right>": "word →", "i_<S-Left>": "← word", "i_<S-Right>": "word →",
"i_<C-Home>": "file start", "i_<C-End>": "file end", "i_<S-Up>": "page ↑", "i_<S-Down>": "page ↓",
"o_v": "charwise", "o_V": "linewise", "o_CTRL-V": "blockwise",
};
const STOP = new Set(["the", "a", "an", "to", "of", "n", "nth", "and", "or", "with", "in", "for", "on", "at", "is", "it", "go",
"cursor", "under", "current", "from", "that", "this", "by", "be", "lines", "times", "same", "as", "into", "when", "if", "its", "vim.lsp.buf", "inside", "around", "inner", "…"]);
const UNHELPFUL = /^\((lua function|disabled)\)$|^runs |^:help /i;
/** A 1–2 word label from a description: "Toggle Undotree" → "Toggle Undotree", "same as "j"" → "= j". */
function deriveLabel(d) {
d = String(d || "").trim();
if (!d) return "";
let m = d.match(/^same as\s+["`|]?([^"`|]+?)["`|]?(\s|$)/i);
if (m) return "= " + m[1].trim();
m = d.match(/^vim\.lsp\.buf\.([a-z_]+)\(\)/);
if (m) return m[1].replace(/_/g, " ");
m = d.match(/^:(\S+)/);
if (m) return ":" + m[1];
const words = d.replace(/\([^)]*\)|\[[^\]]*\]/g, " ").replace(/["'`|,.:;]/g, " ").split(/\s+/)
.filter((w) => w && !STOP.has(w.toLowerCase()));
// Sentence-case like the hand-written labels ("Clear search" → "clear search"), keeping acronyms (LSP).
return words.slice(0, 2).map((w) => (/^[A-Z][a-z]/.test(w) ? w[0].toLowerCase() + w.slice(1) : w)).join(" ");
}
function labelOf(x) {
if (!x) return "";
if (x.sl) return x.sl;
if (x.kind === "builtin" && x.tag && SL[x.tag] !== undefined && SL[x.tag] !== "") return SL[x.tag];
if (x.kind === "live" && UNHELPFUL.test(x.d)) {
// A Lua callback without a description: use the meaning of the built-in it replaces.
const rep = arr(x.ref.rep).map((r) => builtAt(r.b)).find(Boolean);
if (rep) return labelOf(rep);
if (/^\(disabled\)$/.test(x.d)) return "disabled";
}
return deriveLabel(x.d);
}
// ---- Lookup by key sequence -------------------------------------------------------------------
// IDX[mode] maps the first base token to every entry that can be typed in that mode.
const IDX = {};
for (const x of ALL) {
if (x.ex || !x.btok.length) continue;
for (const m of new Set(x.m.flatMap((m) => (m === "v" ? ["x", "s"] : [m])))) {
const map = IDX[m] || (IDX[m] = new Map());
const k = x.btok[0];
if (!map.has(k)) map.set(k, []);
map.get(k).push(x);
}
}
const KIND_RANK = { live: 0, curated: 1, builtin: 2, cmd: 3 };
/**
* Everything bound to `seq` in `mode`:
* exact – entries whose keys are exactly seq ("w", "<Space>ff")
* belongs – seq followed only by placeholders (f{char}, ["x]d{motion})
* kids – longer sequences that start with seq (prefix maps)
* primary is what actually happens: exact live maps win over built-ins.
*/
function lookup(mode, seq) {
const cand = (IDX[mode] && IDX[mode].get(seq[0])) || [];
const exact = [], belongs = [], kids = [];
for (const x of cand) {
const t = x.btok;
if (t.length < seq.length) continue;
let ok = true;
for (let i = 1; i < seq.length; i++) if (t[i] !== seq[i]) { ok = false; break; }
if (!ok) continue;
if (t.length === seq.length) exact.push(x);
// "f{char}", "/{pattern}<CR>": the key itself, waiting for an argument.
else if (isPh(t[seq.length])) belongs.push(x);
else kids.push(x);
}
const rank = (a, b) => (a.ctx ? 1 : 0) - (b.ctx ? 1 : 0) || KIND_RANK[a.kind] - KIND_RANK[b.kind];
exact.sort(rank); belongs.sort(rank);
const main = [...exact, ...belongs];
let primary = main.find((x) => !x.ctx && x.kind === "live") || main.find((x) => !x.ctx && x.kind === "curated")
|| exact.find((x) => !x.ctx) || belongs.find((x) => !x.ctx) || null;
// A disabled prefix key (mini.surround's "s") is really just a group of longer keys.
// Keys that only exist in some context (a plugin window, a filetype) never label the cap.
const mainKids = kids.filter((x) => !x.ctx);
// The leader key itself: its built-in meaning (Space = "l") is not what anyone presses it for.
const isLeader = seq.length === 1 && seq[0] === LEADER_TOK && mainKids.some((x) => x.ld);
const asGroup = mainKids.length && (isLeader || !primary || (primary.kind === "live" && /^\(disabled\)$/.test(primary.d)));
return { mode, seq, exact, belongs, kids, mainKids, primary, asGroup: !!asGroup, isLeader };
}
function groupName(seq, kids) {
if (seq.length === 1 && seq[0] === LEADER_TOK && kids.some((x) => x.ld)) return "leader…";
const g = GROUPS.get(seq.join(""));
if (g && g.name) return g.name.replace(/^\+/, "");
const srcs = new Set(kids.filter((k) => !k.ctx).map((k) => k.s));
if (srcs.size === 1) {
const s = [...srcs][0];
if (s !== "builtin" && s !== "config" && s !== "neovim") return s.replace(/^mini\./, "").replace(/[.-]nvim$/, "") + "…";
}
const uniq = [...new Set(kids.map((k) => k.btok.slice(seq.length).map(glyphTok).join("")))];
if (uniq.length === 1) return "→ " + uniq[0];
return `${glyphTok(seq[seq.length - 1])} prefix`;
}
// ---- What's different -------------------------------------------------------------------------
{
const notes = arr(D.notes);
const cards = notes.map((nt) => {
nt = obj(nt);
const kind = ["change", "warn", "tip"].includes(nt.kind) ? nt.kind : "change";
const kindLabel = { change: "changed", warn: "watch out", tip: "tip" }[kind];
const rows = arr(nt.rows).map((r) => `<dt>${arr(r)[0] ?? ""}</dt><dd>${arr(r)[1] ?? ""}</dd>`).join("");
// Note fields (lead, rows, note) are HTML by design; only the title is escaped.
return { kind, html: `<div class="${kind}"><b class="t">${esc(nt.title || "")}<em>${kindLabel}</em></b>${nt.lead ? `<p class="lead">${nt.lead}</p>` : ""}${rows ? `<dl>${rows}</dl>` : ""}${nt.note ? `<p>${nt.note}</p>` : ""}</div>` };
});
// Kind chips + a "show all" fold so 30-odd cards don't bury the rest of the page.
const NOTE_FOLD = 9;
const NK = { all: "All", warn: "Watch out", change: "Changed", tip: "Tips" };
let nkind = "all", nall = false;
function drawNotes() {
const list = cards.filter((c) => nkind === "all" || c.kind === nkind);
const show = nall ? list : list.slice(0, NOTE_FOLD);
$("notecards").innerHTML = show.map((c) => c.html).join("");
$("notemore").hidden = list.length <= NOTE_FOLD;
$("notemore").textContent = nall ? "Show fewer cards" : `Show all ${list.length} cards`;
$("notemore").setAttribute("aria-expanded", String(nall));
for (const b of $("notechips").children) b.setAttribute("aria-pressed", String(b.dataset.v === nkind));
}
$("notechips").innerHTML = Object.entries(NK).map(([k, l]) => {
const n = k === "all" ? cards.length : cards.filter((c) => c.kind === k).length;
return n ? `<button type="button" class="chip" data-v="${k}" aria-pressed="${k === nkind}">${l}<span>${n}</span></button>` : "";
}).join("");
$("notechips").addEventListener("click", (e) => { const b = e.target.closest(".chip"); if (b) { nkind = b.dataset.v; drawNotes(); } });
$("notemore").addEventListener("click", () => { const was = nall; nall = !nall; drawNotes(); if (was) scrollToEl($("notechips")); });
drawNotes();
if (!cards.length) { $("notecards").hidden = true; $("notechips").hidden = true; }
const lists = [];
// 1. Built-ins replaced by a mapping, grouped by who replaced them.
const pairs = new Map(); // "b|live" → {B, L, modes}
for (const B of BUILT) {
for (const o of arr(B.ref.ov)) {
const L = liveAt(o.by);
if (!L) continue;
const key = `${B.kf}|${B.d}|${o.by}`;
if (!pairs.has(key)) pairs.set(key, { B, L, modes: new Set() });
pairs.get(key).modes.add(o.m);
}
}
const bySrc = groupBy([...pairs.values()], (p) => p.L.s);
const srcOrder = [...bySrc.keys()].sort((a, b) => ["config", "plugin", "neovim", "builtin"].indexOf(srcKind(a)) - ["config", "plugin", "neovim", "builtin"].indexOf(srcKind(b)) || a.localeCompare(b));
if (pairs.size) {
lists.push(`<details class="dl"><summary><h3>Built-ins replaced by a mapping <span class="count">${pairs.size}</span></h3></summary>
<p>The original Vim meaning (struck through) and what the key does now.</p>
${srcOrder.map((s) => `<div class="dgroup"><h4>${srcBadge(s)} <span>${bySrc.get(s).length}</span></h4>${bySrc.get(s).map(({ B, L, modes }) => `
<div class="drow"><div>${keysHTML(B)}</div><div><span class="was">${esc(B.d)}</span><span class="arrow" aria-hidden="true">→</span><b>${esc(L.d)}</b>
<div class="meta">${modeBadges([...modes])}${L.ctx ? `<span class="ctx">${esc(L.ctx)}</span>` : ""}${fileLine(L)}</div></div></div>`).join("")}</div>`).join("")}
</details>`);
}
// 2. Mappings made unreachable by a shorter nowait map.
const dead = LIVE.filter((x) => arr(x.ref.sh).length);
if (dead.length) {
lists.push(`<details class="dl"><summary><h3>Mappings made unreachable <span class="count">${dead.length}</span></h3></summary>
<p>A shorter <code>nowait</code> map fires as soon as its keys are typed, so these longer maps can never be reached.</p>
<div class="dgroup">${dead.map((x) => { const by = arr(x.ref.sh).map((s) => liveAt(s.by)).filter(Boolean)[0]; return `
<div class="drow"><div>${keysHTML(x)}</div><div><span class="was">${esc(x.d)}</span> ${srcBadge(x.s)}
<div class="meta">${by ? `blocked by ${keysHTML(by)} ${esc(by.d)} · ${fileLine(by)}` : ""}</div></div></div>`; }).join("")}</div></details>`);
}
// 3. Complete built-ins that now wait 'timeoutlen' because a mapping extends them.
const waits = BUILT.filter((x) => arr(x.ref.wait).length);
if (waits.length) {
lists.push(`<details class="dl"><summary><h3>Keys that now wait ${esc(TIMEOUT)} ms <span class="count">${waits.length}</span></h3></summary>
<p>These built-ins still work, but because longer mappings start with the same keys Neovim pauses for <code>'timeoutlen'</code> (${esc(TIMEOUT)} ms) before running them. Type the next key sooner to pick a longer map.</p>
<div class="dgroup">${waits.map((x) => `
<div class="drow"><div>${keysHTML(x)}</div><div>${esc(x.d)}<div class="meta">${arr(x.ref.wait).map((w) =>
`<span>${modeBadges([w.m])} ${arr(w.ext).map((k) => `<code>${esc(k)}</code>`).join(" ")}${w.n > arr(w.ext).length ? ` +${w.n - arr(w.ext).length} more` : ""}</span>`).join("")}</div></div></div>`).join("")}</div></details>`);
}
// 4. Printable keys mapped in Select mode (snippet placeholders are Select mode).
const traps = LIVE.filter(selectTrap);
if (traps.length) {
lists.push(`<details class="dl"><summary><h3>Printable keys mapped in Select mode <span class="count">${traps.length}</span></h3></summary>
<p>Snippet placeholders are selected in Select mode, where typing a printable key should replace the placeholder. These maps (set with mode <code>"v"</code>, which includes Select) run instead${traps.some((x) => x.tok[0] === "<Space>") ? `, and a typed <kbd>Space</kbd> waits ${esc(TIMEOUT)} ms for the leader maps` : ""}. Mapping <code>"x"</code> instead of <code>"v"</code> avoids this.</p>
<div class="dgroup">${traps.map((x) => `
<div class="drow"><div>${keysHTML(x)}</div><div>${esc(x.d)} ${srcBadge(x.s)}<div class="meta">${modeBadges(x.m)}${fileLine(x)}</div></div></div>`).join("")}</div></details>`);
}
$("difflists").innerHTML = lists.join("") || `<p class="muted">No differences detected.</p>`;
}
// ---- Keyboard ---------------------------------------------------------------------------------
// Two physical boards in key units (1u = one letter key): a standard US ANSI tenkeyless board and
// the full-size QMK board (Keychron Q5 Max) from the QMK cheatsheet. Letters are QWERTY on both, so a
// key does the same Vim thing on either; only where it sits (and which keys exist) changes.
// Printable keys carry base/shifted characters, named keys a Vim key name, modifier keys are toggles.
const pair = (b, s, w) => ({ b, s, w });
const letters = (s) => s.split("").map((c) => pair(c, c.toUpperCase()));
const fk = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => ({ n: "F" + (a + i) }));
const numrow = [pair("`", "~"), pair("1", "!"), pair("2", "@"), pair("3", "#"), pair("4", "$"), pair("5", "%"), pair("6", "^"),
pair("7", "&"), pair("8", "*"), pair("9", "("), pair("0", ")"), pair("-", "_"), pair("=", "+")];
const kp = (n, label, h) => ({ n, label, h });
function buildBoard(w, h, rows) {
const keys = [];
for (const [y, x0, items] of rows) {
let x = x0;
for (const it of items) {
const kw = it.w || 1;
if (!it.gap) keys.push({ ...it, x, y, w: kw, h: it.h || 1 });
x += kw;
}
}
return { w, h, keys };
}
const BOARDS = {
standard: { name: "Standard", ...buildBoard(18.25, 6.25, [
[0, 0, [{ n: "Esc" }, { gap: 1 }, ...fk(1, 4), { gap: 1, w: 0.5 }, ...fk(5, 8), { gap: 1, w: 0.5 }, ...fk(9, 12)]],
[1.25, 0, [...numrow, { n: "BS", w: 2, label: "Backspace" }]],
[1.25, 15.25, [{ n: "Insert", label: "Ins" }, { n: "Home" }, { n: "PageUp", label: "PgUp" }]],
[2.25, 0, [{ n: "Tab", w: 1.5 }, ...letters("qwertyuiop"), pair("[", "{"), pair("]", "}"), pair("\\", "|", 1.5)]],
[2.25, 15.25, [{ n: "Del" }, { n: "End" }, { n: "PageDown", label: "PgDn" }]],
[3.25, 0, [{ mod: "Caps", w: 1.75 }, ...letters("asdfghjkl"), pair(";", ":"), pair("'", '"'), { n: "CR", w: 2.25, label: "Enter" }]],
[4.25, 0, [{ mod: "Shift", w: 2.25 }, ...letters("zxcvbnm"), pair(",", "<"), pair(".", ">"), pair("/", "?"), { mod: "Shift", w: 2.75 }]],
[4.25, 16.25, [{ n: "Up", label: "↑" }]],
[5.25, 0, [{ mod: "Ctrl", w: 1.25 }, { mod: "Super", w: 1.25 }, { mod: "Alt", w: 1.25 }, { n: "Space", w: 6.25 },
{ mod: "Alt", w: 1.25 }, { mod: "Super", w: 1.25 }, { mod: "Menu", w: 1.25 }, { mod: "Ctrl", w: 1.25 }]],
[5.25, 15.25, [{ n: "Left", label: "←" }, { n: "Down", label: "↓" }, { n: "Right", label: "→" }]],
]) },
// Positions from the Q5 Max info.json (LAYOUT_ansi_101). It has no Insert key, the nav column is
// PgUp / PgDn / Home-End (a tap dance: tap Home, double-tap End), the right-hand Alt/Menu are the
// FN1/FN2 layer keys, and the top-right keys (Print Screen, Calc, Find, knob) have no Vim meaning.
q5: { name: "Q5 Max (QMK)", ...buildBoard(20.5, 6.5, [
[0, 0, [{ n: "Esc" }, { gap: 1 }, ...fk(1, 4), { gap: 1, w: 0.5 }, ...fk(5, 8), { gap: 1, w: 0.5 }, ...fk(9, 12), { gap: 1, w: 0.25 },
{ n: "Del" }, { n: "PrtSc" }, { n: "Calc" }, { n: "Find" }, { n: "Knob", label: "⏯" }]],
[1.25, 0, [...numrow, { n: "BS", w: 2, label: "Backspace" }, { gap: 1, w: 0.25 }, { n: "PageUp", label: "PgUp" },
{ n: "NumLock", label: "Num" }, kp("kDivide", "/"), kp("kMultiply", "*"), kp("kMinus", "-")]],
[2.25, 0, [{ n: "Tab", w: 1.5 }, ...letters("qwertyuiop"), pair("[", "{"), pair("]", "}"), pair("\\", "|", 1.5), { gap: 1, w: 0.25 },
{ n: "PageDown", label: "PgDn" }, kp("k7", "7"), kp("k8", "8"), kp("k9", "9"), kp("kPlus", "+", 2)]],
[3.25, 0, [{ mod: "Caps", w: 1.75 }, ...letters("asdfghjkl"), pair(";", ":"), pair("'", '"'), { n: "CR", w: 2.25, label: "Enter" }, { gap: 1, w: 0.25 },
{ n: "Home", label: "Home" }, kp("k4", "4"), kp("k5", "5"), kp("k6", "6")]],
[4.25, 0, [{ mod: "Shift", w: 2.25 }, ...letters("zxcvbnm"), pair(",", "<"), pair(".", ">"), pair("/", "?"), { mod: "Shift", w: 1.75 }, { gap: 1, w: 2.5 },
kp("k1", "1"), kp("k2", "2"), kp("k3", "3"), kp("kEnter", "Ent", 2)]],
[4.5, 14.25, [{ n: "Up", label: "↑" }]],
[5.25, 0, [{ mod: "Ctrl", w: 1.25 }, { mod: "Super", w: 1.25 }, { mod: "Alt", w: 1.25 }, { n: "Space", w: 6.25 },
{ mod: "Fn2" }, { mod: "Fn1" }, { mod: "Ctrl" }, { gap: 1, w: 3.5 }, { n: "k0", label: "0", w: 2 }, kp("kPoint", ".")]],
[5.5, 13.25, [{ n: "Left", label: "←" }, { n: "Down", label: "↓" }, { n: "Right", label: "→" }]],
]) },
};
let KB_BOARD = "standard";
try { const b = localStorage.getItem("nvim-cheatsheet-board"); if (BOARDS[b]) KB_BOARD = b; } catch (e) { /* storage blocked: default board */ }
let KEYS = BOARDS[KB_BOARD].keys;
let KB_THEME = "dark";
try { const t = localStorage.getItem("nvim-cheatsheet-kbtheme"); if (t === "light" || t === "dark") KB_THEME = t; } catch (e) { /* storage blocked: default colourway */ }
// Layers: a mode, a prefix typed first, and which modifiers start held.
const LAYERS = [
{ id: "normal", name: "Normal", mode: "n", prefix: [], led: "var(--h1)", about: "Normal mode: what each key does on its own." },
{ id: "shift", name: "Shift", mode: "n", prefix: [], shift: true, led: "var(--h3)", about: "Normal mode with <kbd>Shift</kbd> held: capitals and the shifted symbols." },
{ id: "ctrl", name: "Ctrl", mode: "n", prefix: [], ctrl: true, led: "var(--h5)", about: "Normal mode with <kbd>Ctrl</kbd> held." },
{ id: "g", name: "g…", mode: "n", prefix: ["g"], led: "var(--h2)", about: "Press <kbd>g</kbd>, then:" },
{ id: "z", name: "z…", mode: "n", prefix: ["z"], led: "var(--h9)", about: "Press <kbd>z</kbd>, then: (folds, scrolling, spelling)" },
{ id: "lb", name: "[…", mode: "n", prefix: ["["], led: "var(--h7)", about: "Press <kbd>[</kbd>, then: (mostly “go backwards to …”)" },
{ id: "rb", name: "]…", mode: "n", prefix: ["]"], led: "var(--h7)", about: "Press <kbd>]</kbd>, then: (mostly “go forwards to …”)" },
{ id: "cw", name: "Ctrl-W…", mode: "n", prefix: ["<C-W>"], led: "var(--h10)", about: "Press <kbd>Ctrl</kbd><kbd>W</kbd>, then: (windows)" },
{ id: "leader", name: "Leader…", mode: "n", prefix: [LEADER_TOK], led: "var(--s-config)", leader: true, about: "Press the leader key, then:" },
{ id: "visual", name: "Visual", mode: "x", prefix: [], led: "var(--h5)", about: "Visual mode (after <kbd>v</kbd>, <kbd>V</kbd> or <kbd>Ctrl</kbd><kbd>V</kbd>)." },
{ id: "insert", name: "Insert (Ctrl)", mode: "i", prefix: [], ctrl: true, namedPlain: true, led: "var(--h4)", about: "Insert mode with <kbd>Ctrl</kbd> held. Named keys (Tab, Enter, arrows…) are shown without Ctrl." },
{ id: "obj", name: "Text objects", mode: "o", prefix: ["i"], ai: true, led: "var(--h2)", about: "After an operator (<kbd>d</kbd>, <kbd>c</kbd>, <kbd>y</kbd>…) or in Visual mode, type <kbd>i</kbd> or <kbd>a</kbd>, then (click <kbd>Shift</kbd> for <code>( { &lt; \" W B</code>):" },
];
const KB = { layer: 0, shift: false, ctrl: false, ai: "i", cur: -1, infos: [] };
/** The Vim token a physical key produces under the current modifiers (null for modifier keys). */
function keyToken(k, layer) {
if (k.mod) return null;
const ctrl = KB.ctrl && !(k.n && layer.namedPlain);
if (k.n) {
const mods = [...(ctrl ? ["C"] : []), ...(KB.shift ? ["S"] : [])];
if (!mods.length) return k.n === "Space" ? "<Space>" : `<${k.n}>`;
return `<${mods.join("-")}-${k.n}>`;
}
const ch = KB.shift ? k.s : k.b;
if (!ctrl) return ch;
if (/^[a-z]$/.test(k.b)) return KB.shift ? `<C-S-${k.b.toUpperCase()}>` : `<C-${k.b.toUpperCase()}>`;
return `<C-${ch}>`;
}
// Ctrl+symbol keys that terminals send as a different control character.
const CTRL_ALIAS = { "<C-6>": "<C-^>", "<C-2>": "<C-@>", "<C-->": "<C-_>", "<C-/>": "<C-_>" };
function infoFor(k, layer) {
const tok = keyToken(k, layer);
if (!tok) return null;
const prefix = layer.ai ? [KB.ai] : layer.prefix;
const seq = [...prefix, tok];
let info = lookup(layer.mode, seq);
if (!info.primary && !info.kids.length && CTRL_ALIAS[tok]) {
const alt = lookup(layer.mode, [...prefix, CTRL_ALIAS[tok]]);
if (alt.primary || alt.kids.length) info = alt;
}
// Ctrl-M is Enter to the terminal, so a map on <CR> (leap) is what <C-M> really runs.
if (tok === "<C-M>") {
const cr = lookup(layer.mode, [...prefix, "<CR>"]);
if (cr.primary && cr.primary.kind === "live") info = cr;
}
info.tok = tok;
return info;
}
function capContent(k, info) {
if (k.mod) return { cls: "modkey", g: esc(k.mod), lb: "", mk: "" };
const tok = info.tok;
const g = esc(k.n && !KB.shift && !(KB.ctrl && !LAYERS[KB.layer].namedPlain) ? k.label || glyphTok(tok) : glyphTok(tok));
const p = info.primary;
if (!p && !info.mainKids.length) return { cls: "none", g, lb: "", mk: "" };
let lb, cat, mk, dead = false;
if (info.asGroup) {
lb = groupName(info.seq, info.mainKids);
const g2 = GROUPS.get(info.seq.join(""));
cat = (g2 && g2.cat) || info.mainKids[0].cat;
mk = `<i class="mk group" aria-hidden="true"></i>`;
} else {
lb = labelOf(p);
cat = p.cat;
const waits = p.kind === "builtin" && arr(p.ref.wait).some((w) => w.m === info.mode);
if (p.kind === "builtin") mk = `<i class="mk builtin${waits ? " wait" : ""}" aria-hidden="true"></i>`;
else mk = `<i class="mk live" style="--mkc:var(--s-${p.sk})" aria-hidden="true"></i>`;
dead = p.kind === "live" && arr(p.ref.sh).some((s) => s.m === info.mode);
}
return { cls: `tint${dead ? " dead" : ""}`, g, lb: esc(lb), mk, cat };
}
function drawKB() {
const layer = LAYERS[KB.layer];
for (const t of $("kbtabs").children) t.setAttribute("aria-selected", String(+t.dataset.i === KB.layer));
$("aiswitch").hidden = !layer.ai;
for (const b of $("aiswitch").children) b.setAttribute("aria-pressed", String(b.dataset.ai === KB.ai));
KB.infos = KEYS.map((k) => infoFor(k, layer));
const B = BOARDS[KB_BOARD];
$("kb").style.aspectRatio = `${B.w} / ${B.h}`;
for (const b of $("kblayout").children) b.setAttribute("aria-pressed", String(b.dataset.board === KB_BOARD));
$("board").dataset.board = KB_BOARD;
$("board").dataset.kb = KB_THEME;
for (const b of $("kbtheme").children) b.setAttribute("aria-pressed", String(b.dataset.kbt === KB_THEME));
const cats = new Map();
$("kb").innerHTML = KEYS.map((k, i) => {
const info = KB.infos[i];
const c = capContent(k, info || {});
if (c.cat) cats.set(c.cat, (cats.get(c.cat) || 0) + 1);
const style = `left:${(k.x / B.w) * 100}%;top:${(k.y / B.h) * 100}%;width:${(k.w / B.w) * 100}%;height:${(k.h / B.h) * 100}%`;
const pressed = k.mod === "Shift" ? KB.shift : k.mod === "Ctrl" ? KB.ctrl : null;
const aria = k.mod ? `${k.mod}${pressed === null ? "" : pressed ? " (held)" : " (not held)"}`
: `${info ? info.seq.join("") : ""}${c.lb ? ": " + c.lb : ""}`;
return `<button type="button" class="key ${c.cls}${i === KB.cur ? " cur" : ""}" style="${style}" data-i="${i}"${pressed === null ? "" : ` aria-pressed="${pressed}"`}${k.mod && pressed === null ? ' tabindex="-1"' : ""} aria-label="${esc(aria)}">
<span class="cap" style="${c.cat ? `--cat:${catVar(c.cat)}` : ""}">${c.mk || ""}<span class="g">${c.g}</span><span class="lb">${c.lb}</span></span></button>`;
}).join("");
const sorted = [...cats.entries()].sort((a, b) => catRank(a[0]) - catRank(b[0]));
$("kblegend").innerHTML = sorted.map(([c]) => `<span class="c" style="--cat:${catVar(c)}">${esc(c)}</span>`).join("")
+ `<span><i class="mk live" style="--mkc:var(--s-config)"></i>your map</span><span><i class="mk live" style="--mkc:var(--s-plugin)"></i>plugin map</span>`
+ `<span><i class="mk live" style="--mkc:var(--s-neovim)"></i>Neovim default</span><span><i class="mk builtin"></i>plain built-in</span>`
+ `<span><i class="mk builtin wait"></i>waits ${esc(TIMEOUT)} ms</span><span><i class="mk group"></i>prefix: more keys follow</span>`;
const prefixCaps = layer.ai ? `<kbd>${esc(KB.ai)}</kbd>` : "";
const held = [KB.ctrl ? "Ctrl" : "", KB.shift ? "Shift" : ""].filter(Boolean);
$("layermeta").innerHTML = `${layer.about} ${prefixCaps}${layer.leader ? ` ${tokCaps([LEADER_TOK], { ld: true })}` : ""}${held.length ? ` <span class="muted">· holding ${held.map((h) => `<kbd>${h}</kbd>`).join(" ")}</span>` : ""}`;
if (KB.cur >= 0 && KB.infos[KB.cur]) showKey(KB.cur);
else $("keyinfo").innerHTML = `<span class="muted">Hover or focus a key to see everything bound to it in this layer.</span>`;
}
$("kbtabs").innerHTML = LAYERS.map((l, i) =>
`<button type="button" class="tab" role="tab" id="kbtab-${l.id}" style="--led:${l.led}" aria-selected="${i === KB.layer}" aria-controls="kb" data-i="${i}"><i></i>${esc(l.name)}</button>`).join("");
function selectLayer(i) {
KB.layer = i;
KB.shift = !!LAYERS[i].shift;
KB.ctrl = !!LAYERS[i].ctrl;
KB.cur = -1;
drawKB();
}
$("kbtabs").addEventListener("click", (e) => { const b = e.target.closest(".tab"); if (b) selectLayer(+b.dataset.i); });
// Arrow keys move between tabs (tablist pattern).
function tabArrows(listEl, onPick) {
listEl.addEventListener("keydown", (e) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
const tabs = [...listEl.querySelectorAll('[role="tab"]')];
const i = tabs.indexOf(document.activeElement);
if (i < 0) return;
e.preventDefault();
const j = e.key === "Home" ? 0 : e.key === "End" ? tabs.length - 1 : (i + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
onPick(tabs[j]);
const again = [...listEl.querySelectorAll('[role="tab"]')][j];
if (again) again.focus();
});
}
tabArrows($("kbtabs"), (t) => selectLayer(+t.dataset.i));
$("kblayout").innerHTML = Object.entries(BOARDS).map(([id, b]) => `<button type="button" data-board="${id}" aria-pressed="${id === KB_BOARD}">${esc(b.name)}</button>`).join("");
$("kblayout").addEventListener("click", (e) => {
const b = e.target.closest("[data-board]");
if (!b || b.dataset.board === KB_BOARD) return;
KB_BOARD = b.dataset.board;
KEYS = BOARDS[KB_BOARD].keys;
KB.cur = -1;
try { localStorage.setItem("nvim-cheatsheet-board", KB_BOARD); } catch (err) { /* storage blocked: choice isn't remembered */ }
drawKB();
});
$("kbtheme").addEventListener("click", (e) => {
const b = e.target.closest("[data-kbt]");
if (!b || b.dataset.kbt === KB_THEME) return;
KB_THEME = b.dataset.kbt;
try { localStorage.setItem("nvim-cheatsheet-kbtheme", KB_THEME); } catch (err) { /* storage blocked: choice isn't remembered */ }
drawKB();
});
$("aiswitch").addEventListener("click", (e) => { const b = e.target.closest("[data-ai]"); if (b) { KB.ai = b.dataset.ai; drawKB(); } });
/** Detail panel: every entry for the key in this layer, then the longer keys it starts. */
function entryLI(x, mode) {
const struck = x.kind === "builtin" && arr(x.ref.ov).some((o) => o.m === mode);
return `<li><div class="top">${keysHTML(x)} <span class="${struck ? "was" : ""}">${esc(x.d)}</span></div>
<div class="rm">${srcBadge(x.s)}${x.ctx ? `<span class="ctx">${esc(x.ctx)}</span>` : ""}<span class="catb" style="--cat:${catVar(x.cat)}">${esc(x.cat)}</span>${modeBadges(x.m)}</div>
${(() => { const f = flagsHTML(x, mode); return f ? `<div class="rflags">${f}</div>` : ""; })()}
${x.n ? `<div class="rn">${x.n}</div>` : ""}
<div class="rf">${fileLine(x)}${helpHTML(x)}</div></li>`;
}
function showKey(i) {
const info = KB.infos[i];
if (!info) return;
KB.cur = i;
for (const el of $("kb").querySelectorAll(".key.cur")) el.classList.remove("cur");
const el = $("kb").querySelector(`.key[data-i="${i}"]`);
if (el) el.classList.add("cur");
const layer = LAYERS[KB.layer];
const mode = info.mode;
const head = `<div class="ki-head">${tokCaps(info.seq, { ld: !!layer.leader })}<span class="muted">in ${esc(modeNames([mode]))} mode</span></div>`;
const main = [...info.exact, ...info.belongs];
const kids = info.kids.slice().sort((a, b) => KIND_RANK[a.kind] - KIND_RANK[b.kind] || a.kf.localeCompare(b.kf));
// One chip per distinct continuation; the live map wins over the built-in it replaces.
const seen = new Map();
for (const k of kids) { const rest = k.btok.slice(info.seq.length).join(""); if (!seen.has(rest)) seen.set(rest, k); }
const chips = [...seen.values()];
const MAX = 40;
const g = info.asGroup ? GROUPS.get(info.seq.join("")) : null;
$("keyinfo").innerHTML = head
+ (main.length ? `<ul class="ki-list">${main.map((x) => entryLI(x, mode)).join("")}</ul>` : (info.kids.length ? "" : `<p class="muted">Nothing is bound to this key here.</p>`))
+ (chips.length ? `<div><p class="muted">${g && g.name ? `<b>${esc(g.name)}</b> · ` : ""}Then press (${chips.length}):</p><div class="kids">${chips.slice(0, MAX).map((k) =>
`<span class="kid">${tokCaps(k.btok.slice(info.seq.length))}<span>${esc(k.d)}</span></span>`).join("")}${chips.length > MAX ? `<span class="kid">+${chips.length - MAX} more — search the finder</span>` : ""}</div></div>` : "");
}
$("kb").addEventListener("mouseover", (e) => { const k = e.target.closest(".key"); if (k && KB.infos[+k.dataset.i]) showKey(+k.dataset.i); });
$("kb").addEventListener("focusin", (e) => { const k = e.target.closest(".key"); if (k && KB.infos[+k.dataset.i]) showKey(+k.dataset.i); });
$("kb").addEventListener("click", (e) => {
const el = e.target.closest(".key");
if (!el) return;
const k = KEYS[+el.dataset.i];
if (k.mod === "Shift" || k.mod === "Ctrl") {
if (k.mod === "Shift") KB.shift = !KB.shift; else KB.ctrl = !KB.ctrl;
const i = +el.dataset.i;
drawKB();
const again = $("kb").querySelector(`.key[data-i="${i}"]`);
if (again) again.focus();
return;
}
const info = KB.infos[+el.dataset.i];
if (!info) return;
showKey(+el.dataset.i);
sendToFinder(info.seq.join(""), info.mode);
});
// ---- Leader tree ---------------------------------------------------------------------------------
{
const maps = LIVE.filter((x) => x.ld && !x.ctx && x.tok[0] === LEADER_TOK);
const leaderCap = tokCaps([LEADER_TOK], { ld: true });
$("leaderintro").innerHTML = maps.length
? `${maps.length} maps start with the leader ${leaderCap}${LEADER === " " ? " (Space)" : ""}. Groups follow which-key's names.${arr(META.plugins).includes("which-key") ? " Press the leader and pause (which-key's default delay is 200 ms) to see the popup." : ""}`
: "No leader maps found.";
const groupsSorted = [...GROUPS.entries()].map(([k, g]) => ({ toks: canonKeys(g.k), key: k, ...g })).sort((a, b) => b.toks.length - a.toks.length);
const cards = new Map(); // second token → { items, subs: Map }
const top = [];
const hasKids = (x) => maps.some((y) => y !== x && y.tok.length > x.tok.length && y.tok.slice(0, x.tok.length).join("") === x.kb);
for (const x of maps) {
if (x.tok.length <= 2 && !hasKids(x)) { top.push(x); continue; }
const k2 = x.tok.slice(0, 2).join("");
if (!cards.has(k2)) cards.set(k2, { toks: x.tok.slice(0, 2), items: [], subs: new Map() });
const card = cards.get(k2);
const sub = groupsSorted.find((g) => g.toks.length > 2 && x.tok.length > g.toks.length && g.toks.join("") === x.tok.slice(0, g.toks.length).join(""));
if (sub) {
if (!card.subs.has(sub.key)) card.subs.set(sub.key, { g: sub, items: [] });
card.subs.get(sub.key).items.push(x);
} else card.items.push(x);
}
// Groups with a single item read better at the top level.
for (const [k, c] of [...cards]) if (c.items.length + [...c.subs.values()].reduce((n, s) => n + s.items.length, 0) === 1 && !GROUPS.has(k)) { top.push(...c.items, ...[...c.subs.values()].flatMap((s) => s.items)); cards.delete(k); }
const byKey = (a, b) => a.kb.toLowerCase().localeCompare(b.kb.toLowerCase()) || (a.kb < b.kb ? 1 : -1);
// which-key style: items show only the keys after their card's (or sub-group's) prefix.
const item = (x, skip) => `<div class="litem" title="${esc(x.raw)}">${tokCaps(x.tok.slice(skip))}<span>${esc(x.d)}${x.sk !== "config" ? ` ${srcBadge(x.s)}` : ""}</span><span class="ms">${x.m.length === 1 && x.m[0] === "n" ? "" : modeBadges(x.m)}</span></div>`;
const cardHTML = (toks, name, cat, items, subs) => `<div class="lcard" style="--cat:${catVar(cat)}">
<header>${tokCaps(toks, { ld: true })}<h3>${esc(name)}</h3><span class="count">${items.length + subs.reduce((n, s) => n + s.items.length, 0)}</span></header>
${items.sort(byKey).map((x) => item(x, toks.length)).join("")}
${subs.map((s) => `<h4>${tokCaps(s.g.toks.slice(toks.length))} ${esc(s.g.name || "")}</h4>${s.items.sort(byKey).map((x) => item(x, s.g.toks.length)).join("")}`).join("")}
</div>`;
const out = [];
if (top.length) out.push(cardHTML([LEADER_TOK], "Top level", "Find & pick", top, []));
for (const [k, c] of [...cards].sort((a, b) => a[0].toLowerCase().localeCompare(b[0].toLowerCase()))) {
const g = GROUPS.get(k);
const name = g && g.name ? g.name.replace(/^\+/, "") : `${glyphTok(c.toks[1])} group`;
const cat = (g && g.cat) || (c.items[0] || [...c.subs.values()][0].items[0]).cat;
out.push(cardHTML(c.toks, name, cat, c.items, [...c.subs.values()]));
}
$("ltree").innerHTML = out.join("");
}
// ---- Finder ------------------------------------------------------------------------------------------
// scope: starter = essentials + everything the config adds or changes (the default view; a typed query
// searches everything), ess = essentials, chg = changed by the config, all = everything.
const F = { mode: "all", src: "all", cat: "All", scope: "starter", limit: 100 };
const PAGE = 100;
let shown = [], shownQuery = false, scoredCount = 0;
let composed = null;
const OP_RE = /\{motion\}$/;
function decompose(toks) {
toks = arr(toks).slice();
let count = "";
while (toks.length && /^[1-9]$/.test(toks[0]) || (count && toks.length && /^\d$/.test(toks[0]))) count += toks.shift();
const eff = (list) => list.sort((a, b) => KIND_RANK[a.kind] - KIND_RANK[b.kind])[0];
for (let i = Math.min(toks.length - 1, 2); i >= 1; i--) {
const opK = toks.slice(0, i).join(""), rest = toks.slice(i).join("");
const op = BUILT.find((x) => x.kb === opK && OP_RE.test(x.kf) && x.m.includes("n"))
|| [...CUR, ...LIVE].find((x) => !x.ctx && x.kb === opK && OP_RE.test(x.kf) && x.m.includes("n"));
if (!op) continue;
const usable = (x) => !x.ex && !x.ctx && x.kind !== "cmd" && (modeHas(x, "o") || (x.kind === "builtin" && x.ref.mo && x.m.includes("n")));
// Exact keys first, then "iN{id}"-style entries whose last key is a placeholder ("iNb", "f(").
const mo = eff(ALL.filter((x) => usable(x) && x.kb === rest))
|| eff(ALL.filter((x) => usable(x) && x.kb && rest.length > x.kb.length && rest.startsWith(x.kb)
&& rest.length - x.kb.length === 1 && x.btok.length > x.core.length));
if (mo) return { op, mo, count, keys: toks };
}
return null;
}
const inStarter = (x) => x.e || x.changed || x.sk === "config";
const MODE_CHIPS = [["all", "All"], ["n", "Normal"], ["x", "Visual"], ["o", "Op-pending"], ["i", "Insert"], ["c", "Cmdline"], ["t", "Terminal"], ["s", "Select"], [":", "Ex commands"]];
$("modechips").innerHTML = `<span class="lbl">Mode</span>` + MODE_CHIPS.map(([m, l]) => `<button type="button" class="chip" data-v="${esc(m)}" aria-pressed="${m === F.mode}">${esc(l)}</button>`).join("");
{
const counts = new Map();
for (const x of ALL) counts.set(x.s, (counts.get(x.s) || 0) + 1);
const kinds = { builtin: 0, neovim: 0, config: 0, plugin: 0 };
for (const [s, n] of counts) kinds[srcKind(s)] += n;
// One chip per kind of source; the 30-odd individual plugins go in a menu so the row stays short.
const plugins = [...counts.entries()].filter(([s]) => srcKind(s) === "plugin").sort((a, b) => a[0].localeCompare(b[0]));
$("srcchips").innerHTML = `<span class="lbl">Source</span>`
+ [["all", "All", ALL.length, ""], ["k:builtin", "Built-in", kinds.builtin, "--s-builtin"], ["k:neovim", "Neovim default", kinds.neovim, "--s-neovim"],
["k:config", "Your config", kinds.config, "--s-config"], ["k:plugin", "Plugins", kinds.plugin, "--s-plugin"]]
.filter(([v, , n]) => v === "all" || n > 0)
.map(([v, l, n, c]) => `<button type="button" class="chip" data-v="${esc(v)}" aria-pressed="${v === F.src}">${c ? `<i style="--dot:var(${c})"></i>` : ""}${esc(l)}<span>${n}</span></button>`).join("")
+ (plugins.length ? `<select class="sel" id="srcsel" aria-label="Only one plugin"><option value="">One plugin…</option>${plugins.map(([s, n]) => `<option value="s:${esc(s)}">${esc(s)} (${n})</option>`).join("")}</select>` : "");
}
// Query parsing: "ctrl-w", "<c-w>", "^w", "C-w", "space f", "leader f" all become canonical keys.
const KEY_WORD = { space: "<Space>", spc: "<Space>", leader: LEADER_TOK, localleader: LOCAL_TOK, esc: "<Esc>", escape: "<Esc>",
enter: "<CR>", return: "<CR>", cr: "<CR>", bs: "<BS>", backspace: "<BS>", tab: "<Tab>" };
const MOD_RE = /^(ctrl|control|shift|alt|meta|cmd|super|opt|option|c|s|m|a|d)[-+](.+)$/i;
function partToTokens(p) {
if (p.includes("<")) return canonKeys(p);
const lw = p.toLowerCase();
if (KEY_WORD[lw]) return [KEY_WORD[lw]];
let m = p.match(/^\^(.)(.*)$/);
if (m) return [canonTok(`<C-${m[1]}>`), ...partToTokens(m[2] || "").filter(Boolean)];
const mods = [];
let rest = p;
while ((m = rest.match(MOD_RE))) { mods.push(MOD_ALIAS[m[1].toLowerCase()] || (/^opt/i.test(m[1]) ? "M" : "C")); rest = m[2]; }
if (mods.length) {
const named = NAMED[rest.toLowerCase()];
if (named) return [canonTok(`<${mods.join("-")}-${named}>`)];
return [canonTok(`<${mods.join("-")}-${rest[0]}>`), ...rest.slice(1).split("")];
}
return p.split("");
}
function parseQuery(raw) {
const q = raw.trim();
if (!q) return null;
const lower = q.toLowerCase();
const P = { raw: q, lower, ex: q.startsWith(":") };
P.words = (P.ex ? lower.slice(1) : lower).split(/\s+/).filter(Boolean);
if (P.ex) {
P.name = q.slice(1).trim().split(/\s+/)[0] || "";
P.nameLower = P.name.toLowerCase();
return P;
}
const parts = q.replace(/<leader>/gi, " leader ").replace(/<localleader>/gi, " localleader ").trim().split(/\s+/);
const toks = [];
for (let i = 0; i < parts.length; i++) {
let p = parts[i];
// "ctrl w", "shift tab": a lone modifier word applies to the next word.
if (/^(ctrl|control|shift|alt|meta|cmd|super)$/i.test(p) && i + 1 < parts.length) p = `${p}-${parts[++i]}`;
toks.push(...partToTokens(p));
}
P.toks = toks;
P.key = toks.join("");
P.keyLower = P.key.toLowerCase();
return P;
}
function score(x, P) {
if (P.ex) {
if (!x.ex) return 0;
let s = 0;
if (!P.name) s = 1;
else if (x.exAbbr === P.name || x.exFull === P.name) s = 1000;
else if (x.exFull.startsWith(P.name) && P.name.length >= x.exAbbr.length) s = 800;
else if (x.exFull.toLowerCase().startsWith(P.nameLower)) s = 500 - Math.min(x.exFull.length, 100);
if (!s && P.words.length && P.words.every((w) => x.hay.includes(w))) s = 100;
return s ? s + (x.kind === "cmd" ? 5 : 0) : 0;
}
let s = 0;
const k = P.key;
if (k && !x.ex) {
if (x.kb === k || x.kf === k) s = 1000;
else if (x.kh === k) s = 950;
else if (x.kbl === P.keyLower) s = 600;
else if (x.kb.startsWith(k)) s = 400 - Math.min(x.kb.length - k.length, 50) * 4;
else if (k.length > 1 && x.kbl.startsWith(P.keyLower)) s = 250;
}
// Words: short ones (1–2 chars) must be whole words so "d" doesn't match every description.
const okWords = P.words.every((w) => (w.length < 3 && /^[a-z0-9_]+$/.test(w) ? x.words.has(w) : x.hay.includes(w)));
if (okWords) {
let ds = 100;
if (x.dl.startsWith(P.lower)) ds += 60;
else if (x.dl.includes(P.lower)) ds += 30;
s = Math.max(s, ds);
}
if (!s) return 0;
// Global keys before ones that only work in a plugin window or filetype; Normal mode first.
return s + (x.e ? 15 : 0) + (x.kind === "live" ? 8 : 0) - (arr(x.ref.ov).length ? 5 : 0) - (x.ctx ? 60 : 0) + (x.m.includes("n") ? 3 : 0);
}
const modeHas = (x, m) => x.m.includes(m) || (x.m.includes("v") && (m === "x" || m === "s"));
function srcOK(x) {
if (F.src === "all") return true;
if (F.src.startsWith("k:")) return x.sk === F.src.slice(2);
return x.s === F.src.slice(2);
}
const byDefault = (a, b) => catRank(a.cat) - catRank(b.cat) || (b.e - a.e) || KIND_RANK[a.kind] - KIND_RANK[b.kind] || a.kf.localeCompare(b.kf);
function rowHTML(x) {
const flags = flagsHTML(x);
const struck = x.kind === "builtin" && arr(x.ref.ov).length && x.m.every((m) => arr(x.ref.ov).some((o) => o.m === m));
const rhs = x.kind === "live" && x.ref.rhs && !/^runs /.test(x.d) ? `<span class="fl">rhs <code>${esc(x.ref.rhs)}</code></span>` : "";
const extra = x.kind === "cmd" ? [x.ref.na && x.ref.na !== "0" ? `<span class="fl">args ${esc(x.ref.na)}</span>` : "", x.ref.r ? `<span class="fl">range</span>` : ""].join("") : "";
const fl = fileLine(x);
return `<article class="row${struck ? " dimmed" : ""}" style="--cat:${catVar(x.cat)}">
<div class="rk">${keysHTML(x)}</div>
<div class="rb">
<p class="rd">${esc(x.d || "(no description)")}</p>
<div class="rm">${modeBadges(x.m)}${srcBadge(x.s)}<span class="catb" style="--cat:${catVar(x.cat)}">${esc(x.cat)}</span>${x.e ? `<span class="badge" title="Essential">★ essential</span>` : ""}${x.ctx ? `<span class="ctx">${esc(x.ctx)}</span>` : ""}${fl ? `<span class="fl">${fl}</span>` : ""}${rhs}${extra}${helpHTML(x)}</div>
${flags ? `<div class="rflags">${flags}</div>` : ""}
${x.n ? `<div class="rn">${x.n}</div>` : ""}
</div></article>`;
}
/** Rows from..to, with a category heading wherever the category changes (browse view only). */
function rowsHTML(from, to) {
const heads = !shownQuery && F.cat === "All";
let out = "";
for (let i = from; i < Math.min(to, shown.length); i++) {
const x = shown[i];
if (heads && (i === 0 || shown[i - 1].cat !== x.cat)) {
out += `<h3 class="rcat" style="--cat:${catVar(x.cat)}">${esc(x.cat)}</h3>`;
}
out += rowHTML(x);
}
return out;
}
const SCOPE_HINT = {
starter: "The keys worth knowing plus everything your config adds or changes. Typing searches every key.",
ess: "Only the essentials.", chg: "Only built-ins your config replaces or delays, and maps that override them.", all: "",
};
function runFinder(keepLimit) {
const P = parseQuery($("q").value);
// Browsing shows the chosen scope; a typed search in the default scope looks through everything.
const scope = P && F.scope === "starter" ? "all" : F.scope;
const exOK = F.mode === ":" || (F.mode === "all" && P && P.ex);
const scored = [];
for (const x of ALL) {
if (x.ex ? !exOK : (F.mode === ":" || (F.mode !== "all" && !modeHas(x, F.mode)))) continue;
if (!srcOK(x)) continue;
if (scope === "starter" ? !inStarter(x) : scope === "ess" ? !x.e : scope === "chg" ? !x.changed : false) continue;
const s = P ? score(x, P) : 1;
if (s > 0) scored.push([s, x]);
}
// "ciw", "3dap", "gUiw": no entry has those keys, so explain them as operator + motion/text object
// and put those two entries first.
composed = P && !P.ex && !scored.some(([sc]) => sc >= 1000) ? decompose(P.toks) : null;
if (composed) {
const rest = scored.filter(([, x]) => x !== composed.op && x !== composed.mo);
scored.length = 0;
scored.push([5000, composed.op], [4999, composed.mo], ...rest);
}
// Category chips count what the other filters and the query leave.
const counts = new Map();
for (const [, x] of scored) counts.set(x.cat, (counts.get(x.cat) || 0) + 1);
const cats = [...counts.keys()];
if (F.cat !== "All" && !counts.has(F.cat)) cats.push(F.cat);
cats.sort((a, b) => catRank(a) - catRank(b));
$("catchips").innerHTML = `<span class="lbl">Category</span><button type="button" class="chip" data-v="All" aria-pressed="${F.cat === "All"}">All<span>${scored.length}</span></button>`
+ cats.map((c) => `<button type="button" class="chip" data-v="${esc(c)}" aria-pressed="${F.cat === c}"><i style="--dot:${catVar(c)}"></i>${esc(c)}<span>${counts.get(c) || 0}</span></button>`).join("");
scoredCount = scored.length;
const list = F.cat === "All" ? scored : scored.filter(([, x]) => x.cat === F.cat);
list.sort(P ? (a, b) => b[0] - a[0] || KIND_RANK[a[1].kind] - KIND_RANK[b[1].kind] || a[1].kf.length - b[1].kf.length || a[1].kf.localeCompare(b[1].kf) : (a, b) => byDefault(a[1], b[1]));
shown = list.map(([, x]) => x);
shownQuery = !!P;
if (!keepLimit) F.limit = PAGE;
$("count").textContent = `${shown.length.toLocaleString()} / ${ALL.length.toLocaleString()}`;
$("scopehint").textContent = P && F.scope === "starter" ? "Searching every key." : SCOPE_HINT[F.scope] || "";
renderRows();
}
function moreButton() {
const rest = shown.length - F.limit;
$("more").hidden = rest <= 0;
$("more").textContent = rest > 0 ? `Show ${Math.min(rest, PAGE)} more (${rest.toLocaleString()} left)` : "";
}
function renderRows() {
const P = parseQuery($("q").value);
const hint = P && !P.ex && F.mode !== ":" && !shown.length ? " Start with “:” to search Ex commands." : "";
const elsewhere = F.cat !== "All" && !shown.length ? scoredCount : 0;
const comp = composed ? `<p class="compose">${tokCaps([...composed.count.split("").filter(Boolean), ...composed.keys])} <span>=</span> ${keysHTML(composed.op, composed.op.core)} <span class="muted">${esc(composed.op.d.replace(/\s*\[into register x\]/, "").replace(/\{motion\}/, "…"))}</span> <span>+</span> ${keysHTML(composed.mo, composed.mo.core)} <span class="muted">${esc(composed.mo.d)}</span>${composed.count ? ` <span class="muted">· count ${esc(composed.count)}</span>` : ""} · <a href="#composer">composer</a></p>` : "";
$("results").innerHTML = comp + (shown.length ? rowsHTML(0, F.limit)
: `<p class="empty">Nothing matches${P ? ` “${esc(P.raw)}”` : ""}${F.cat !== "All" ? ` in ${esc(F.cat)}` : ""} with these filters.${elsewhere ? ` ${elsewhere} in other categories: pick <b>All</b> above.` : hint}</p>`);
moreButton();
}
$("more").addEventListener("click", () => {
const from = F.limit;
F.limit += PAGE;
$("results").insertAdjacentHTML("beforeend", rowsHTML(from, F.limit));
moreButton();
});
let qFrame = 0;
$("q").addEventListener("input", () => { cancelAnimationFrame(qFrame); qFrame = requestAnimationFrame(() => runFinder()); });
$("q").addEventListener("keydown", (e) => { if (e.key === "Escape") { $("q").value = ""; runFinder(); } });
function setChip(el, v) { for (const c of el.querySelectorAll(".chip")) c.setAttribute("aria-pressed", String(c.dataset.v === v)); }
function chipGroup(el, key, after) {
el.addEventListener("click", (e) => {
const b = e.target.closest(".chip");
if (!b) return;
F[key] = b.dataset.v;
setChip(el, F[key]);
if (after) after();
runFinder();
});
}
const srcSel = () => $("srcsel");
function syncSrcSel() { const s = srcSel(); if (!s) return; s.value = F.src.startsWith("s:") ? F.src : ""; s.classList.toggle("on", !!s.value); }
chipGroup($("modechips"), "mode");
chipGroup($("srcchips"), "src", syncSrcSel);
chipGroup($("catchips"), "cat");
if (srcSel()) srcSel().addEventListener("change", () => { F.src = srcSel().value || "all"; setChip($("srcchips"), F.src); syncSrcSel(); runFinder(); });
function setScope(v) {
F.scope = v;
for (const b of $("scope").children) b.setAttribute("aria-pressed", String(b.dataset.scope === v));
}
$("scope").addEventListener("click", (e) => { const b = e.target.closest("[data-scope]"); if (b) { setScope(b.dataset.scope); runFinder(); } });
$("reset").addEventListener("click", () => {
Object.assign(F, { mode: "all", src: "all", cat: "All" });
setScope("starter");
setChip($("modechips"), "all"); setChip($("srcchips"), "all"); syncSrcSel();
runFinder();
});
document.addEventListener("keydown", (e) => {
if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey) return;
const a = document.activeElement;
if (a && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.isContentEditable)) return;
e.preventDefault();
$("q").focus();
$("q").select();
});
/** Keyboard → finder: search the key sequence in that mode. */
function sendToFinder(keys, mode) {
$("q").value = keys;
F.mode = MODE_CHIPS.some(([m]) => m === mode) ? mode : "all";
F.cat = "All";
setChip($("modechips"), F.mode);
runFinder();
toast(`Finder: ${keys} in ${modeNames([mode])} mode — ${shown.length} match${shown.length === 1 ? "" : "es"} below`);
}
runFinder();
// ---- Text-object composer --------------------------------------------------------------------------
{
const hasN = (keys) => BUILT.some((x) => x.kb === keys && x.m.includes("n") && /\{motion\}$/.test(x.kf));
const liveN = (keys) => LIVE.find((x) => x.kb === keys && x.m.includes("n") && !x.ctx);
const OPS = [
["d", "delete"], ["c", "change"], ["y", "yank"], [">", "indent"], ["<", "dedent"], ["=", "re-indent"], ["gu", "lowercase"],
["gU", "uppercase"], ["g~", "toggle the case of"], ["gq", "format"], ["gw", "format (cursor stays put)"], ["g?", "ROT13-encode"],
["!", "filter through a command"], ["zf", "create a fold over"], ["g@", "run 'operatorfunc' on"],
].filter(([k]) => hasN(k)).map(([k, v]) => ({ k, v, src: "builtin" }));
OPS.push({ k: "v", v: "select", src: "builtin" });
const gc = liveN("gc");
if (gc) OPS.push({ k: "gc", v: "toggle comments on", src: gc.s });
const sa = liveN("sa"), sd = liveN("sd"), sr = liveN("sr");
if (sa) OPS.push({ k: "sa", v: "surround", src: sa.s, sa: true });
// Other operators described by the curated data (cat "Operator", keys ending in {motion}).
for (const x of [...CUR, ...LIVE]) {
if (x.cat !== "Operator" || x.ctx || !/\{motion\}$/.test(x.kf) || OPS.some((o) => o.k === x.kb) || !x.kb) continue;
OPS.push({ k: x.kb, v: x.d.replace(/\s*\{motion\}.*$/i, "").replace(/^./, (c) => c.toLowerCase()), src: x.s });
}
if (sd) OPS.push({ k: "sd", v: "delete the surrounding", src: sd.s, sur: "d" });
if (sr) OPS.push({ k: "sr", v: "replace the surrounding", src: sr.s, sur: "r" });
// Config maps that change an operator (e.g. d → "dd): shown under the result.
const opNote = (k) => { const L = liveN(k); return L ? L : null; };
// Objects: built-in from index.txt, then mini.ai ones from the curated data.
const NOUN = { w: "word", W: "WORD", s: "sentence", p: "paragraph", "(": "( ) block", ")": "( ) block", b: "( ) block",
"[": "[ ] block", "]": "[ ] block", "{": "{ } block", "}": "{ } block", B: "{ } block", "<": "< > block", ">": "< > block",
t: "tag block", '"': "double-quoted string", "'": "single-quoted string", "`": "backtick string" };
const objs = new Map(); // id → { id, noun, src, desc }
for (const x of BUILT) {
if (x.cat !== "Text object" || x.ref.sec !== "Text objects" || x.tok.length !== 2 || !/^[ia]$/.test(x.tok[0])) continue;
const id = x.tok[1];
if (!objs.has(id)) objs.set(id, { id, noun: NOUN[id] || x.d, src: "builtin", ent: {} });
const o = objs.get(id);
if (!o.ent[x.tok[0]]) o.ent[x.tok[0]] = x;
}
// Curated objects (mini.ai, the config's own): only concrete ids that exist as both i… and a….
const curObj = CUR.filter((x) => x.cat === "Text object" && !x.ctx && x.tok.length >= 2 && /^[ia]$/.test(x.tok[0]) && !x.tok.some(isPh));
const sides = groupBy(curObj, (x) => x.tok.slice(1).join(""));
for (const [id, list] of sides) {
if (!list.some((x) => x.tok[0] === "i") || !list.some((x) => x.tok[0] === "a")) continue;
const inner = list.find((x) => x.tok[0] === "i");
const prev = objs.get(id);
const noun = MINI_AI_IDS[id] || inner.d.replace(/\([^)]*\)/g, "").replace(/^(inside|inner)\s+/i, "").replace(/^(the|a|an)\s+/i, "").split(/[,:;]/)[0].trim().replace(/^./, (c) => c.toLowerCase());
if (!prev || prev.src === "builtin") objs.set(id, { id, noun: noun || id, src: inner.s, ent: { i: inner, a: list.find((x) => x.tok[0] === "a") } });
}
// With mini.ai active it handles every id in its table (and any non-letter); letters it lacks fall back to Vim.
if (MINI_AI) for (const o of objs.values()) if (MINI_AI_IDS[o.id] && o.src === "builtin") { o.src = "mini.ai"; o.noun = MINI_AI_IDS[o.id]; }
const miniIds = new Set([...objs.values()].filter((o) => o.src !== "builtin").map((o) => o.id));
const ORDER = "wWsp()b[]{}B<>t\"'`qfa?";
const rank = (id) => { const i = ORDER.indexOf(id); return id.length === 1 && i >= 0 ? i : ORDER.length; };
const OBJ = [...objs.values()].sort((x, y) => rank(x.id) - rank(y.id) || x.id.localeCompare(y.id));
const short = (t) => (t.length > 22 ? t.slice(0, 21) + "…" : t);
const SUR = ["(", ")", "[", "]", "{", "}", "<", ">", '"', "'", "`", "b", "q", "t", "f", "?"];
const SUR_NOUN = { "(": "( ) with inner spaces", ")": "( )", "[": "[ ] with inner spaces", "]": "[ ]", "{": "{ } with inner spaces",
"}": "{ }", "<": "< > with inner spaces", ">": "< >", b: "brackets: the nearest ( ), [ ] or { }", q: "quotes: the nearest ' \" or `", t: "tag",
f: "function call", "?": "left/right pair (prompted)", '"': '" "', "'": "' '", "`": "` `" };
const SUR_ADD = { b: "( )", q: '" "', t: "a tag (asks for its name)", f: "a function call (asks for its name)", "?": "a left/right pair it asks for" };
const C = { op: OPS[0] ? OPS[0].k : "d", ai: "i", mod: "", id: OBJ.find((o) => o.id === "w") ? "w" : (OBJ[0] || {}).id, count: "", ch: ")" };
const chipsFor = (key, list, cls = "") => list.map(([v, label, small, extra]) => `<button type="button" class="chip ${cls} ${extra || ""}" data-k="${key}" data-v="${esc(v)}" aria-pressed="${String(C[key]) === String(v)}">${esc(label)}${small ? `<small>${esc(small)}</small>` : ""}</button>`).join("");
$("composerintro").innerHTML = `Operators × text objects. ${MINI_AI ? `In this config <b>mini.ai</b> takes over <kbd>i</kbd> and <kbd>a</kbd>: it handles brackets, quotes, tags, <code>f</code> (function call), <code>a</code> (argument) and more, adds <kbd>${esc(AI_NEXT)}</kbd>/<kbd>${esc(AI_LAST)}</kbd> for the next/last match, and falls back to Vim's own objects for the rest (<code>w</code>, <code>s</code>, <code>p</code>…).` : ""}`;
function render() {
const op = OPS.find((o) => o.k === C.op) || OPS[0];
if (!op) { $("comp").innerHTML = `<p class="muted">No operators found.</p>`; return; }
const isSur = !!op.sur;
const rows = [];
rows.push(`<div class="crow"><span class="lbl">Operator</span><div class="chips">${chipsFor("op", OPS.map((o) => [o.k, o.k, o.v.split(" (")[0], o.src !== "builtin" ? "mini" : ""]))}</div></div>`);
if (!isSur) rows.push(`<div class="crow"><span class="lbl">Scope</span><div class="chips">${chipsFor("ai", [["i", "i", "inside"], ["a", "a", "around"]])}</div></div>`);
// next/last only exist for mini.ai's own objects (AI_NEXT/AI_LAST) and for mini.surround's sd/sr
// (always n/l); for Vim's
// objects (w, s, p) mini.ai falls back to the built-in and the suffix would be typed as an object id.
const curObj = OBJ.find((x) => x.id === C.id);
const modOK = isSur || (MINI_AI && curObj && miniIds.has(curObj.id));
if (modOK) rows.push(`<div class="crow"><span class="lbl">Which one</span><div class="chips">${chipsFor("mod", [["", "nearest", "covering or next"], ["n", isSur ? "n" : AI_NEXT, "next"], ["l", isSur ? "l" : AI_LAST, "last (previous)"]])}</div></div>`);
if (isSur) rows.push(`<div class="crow"><span class="lbl">Surrounding</span><div class="chips">${chipsFor("id", SUR.map((s) => [s, s, ["b", "q", "t", "f", "?"].includes(s) ? short(SUR_NOUN[s]) : ""]), "mini")}</div></div>`);
else {
const bi = OBJ.filter((o) => !miniIds.has(o.id)), mi = OBJ.filter((o) => miniIds.has(o.id));
rows.push(`<div class="crow"><span class="lbl">Object</span><div class="stack" style="gap:8px">
${bi.length ? `<div class="chips"><span class="lbl">Built-in</span>${chipsFor("id", bi.map((o) => [o.id, o.id, short(o.noun)]))}</div>` : ""}
${mi.length ? `<div class="chips"><span class="lbl">${esc([...new Set(mi.map((o) => o.src))].join(" · "))}</span>${chipsFor("id", mi.map((o) => [o.id, o.id, short(o.noun)]), "mini")}</div>` : ""}</div></div>`);
rows.push(`<div class="crow"><label class="lbl" for="ccount">Count</label><div><input id="ccount" type="number" min="1" max="99" inputmode="numeric" value="${esc(C.count)}" placeholder="—" aria-label="Optional count"></div></div>`);
}
if (op.sa || op.sur === "r") rows.push(`<div class="crow"><span class="lbl">${op.sa ? "With" : "Replace with"}</span><div class="chips">${chipsFor("ch", SUR.map((s) => [s, s]), "mini")}</div></div>`);
// Result.
const mod = C.mod && modOK ? C.mod : "";
let keys, sentence, objSrc = "", objEnt = null;
if (isSur) {
if (!SUR.includes(C.id)) C.id = ")";
keys = [...canonKeys(op.k), ...(mod ? [mod] : []), C.id, ...(op.sur === "r" ? [C.ch] : [])];
const which = mod === "n" ? " <span class=\"muted\">(the next one after the cursor)</span>" : mod === "l" ? " <span class=\"muted\">(the previous one)</span>" : "";
sentence = op.sur === "d" ? `<b>delete</b> the surrounding ${esc(SUR_NOUN[C.id])}${which}`
: `<b>replace</b> the surrounding ${esc(SUR_NOUN[C.id])} with ${esc(SUR_ADD[C.ch] || SUR_NOUN[C.ch] || C.ch)}${which}`;
} else {
const o = OBJ.find((x) => x.id === C.id) || OBJ[0];
if (!o) { $("comp").innerHTML = rows.join("") + `<p class="muted">No text objects found.</p>`; return; }
C.id = o.id;
const cnt = /^[1-9]\d?$/.test(C.count) ? C.count : "";
const sfx = mod === "n" ? AI_NEXT : mod === "l" ? AI_LAST : "";
keys = [...canonKeys(op.k), ...(cnt ? cnt.split("") : []), C.ai, ...(sfx ? [sfx] : []), ...canonKeys(o.id), ...(op.sa ? [C.ch] : [])];
const which = mod === "n" ? "the next " : mod === "l" ? "the previous " : "";
const scope = C.ai === "i" ? "inside" : "around";
sentence = `<b>${esc(op.v)}</b> ${scope} ${which}${esc(o.noun)}${cnt ? ` <span class="muted">(count ${esc(cnt)})</span>` : ""}${op.sa ? ` with ${esc(SUR_ADD[C.ch] || SUR_NOUN[C.ch] || C.ch)}` : ""}`;
objSrc = o.src;
objEnt = o.ent[C.ai] || o.ent.i || o.ent.a || null;
}
const note = opNote(op.k);
const noteHTML = note && op.src === "builtin" ? `<p class="muted">Your config maps <kbd>${esc(op.k)}</kbd>: ${esc(note.d)}${note.ref.rhs ? ` (<code>${esc(note.ref.rhs)}</code>)` : ""} · ${fileLine(note)}</p>` : "";
rows.push(`<div class="cout" aria-live="polite">${tokCaps(keys)}<p class="sent">${sentence.replace(/^<b>(.)/, (m, c) => `<b>${c.toUpperCase()}`)}</p>
<div class="rm">${srcBadge(op.src)}<span class="muted">operator</span>${isSur ? "" : `${srcBadge(objSrc)}<span class="muted">text object</span>${objEnt ? `<span class="rf">${helpHTML(objEnt)}</span>` : ""}`}
<button type="button" class="copy" data-copy="${esc(keys.join(""))}">copy keys</button></div>${noteHTML}</div>`);
$("comp").innerHTML = rows.join("");
const cc = $("ccount");
if (cc) cc.addEventListener("input", () => { C.count = cc.value.trim(); render(); const again = $("ccount"); if (again) { again.focus(); const v = again.value; again.value = ""; again.value = v; } });
}
$("comp").addEventListener("click", (e) => {
const b = e.target.closest(".chip[data-k]");
if (!b) return;
const k = b.dataset.k;
C[k] = b.dataset.v;
if (k === "op") { const op = OPS.find((o) => o.k === C.op); if (op && op.sur && !SUR.includes(C.id)) C.id = ")"; if (op && !op.sur && !OBJ.some((o) => o.id === C.id)) C.id = "w"; }
render();
const again = $("comp").querySelector(`.chip[data-k="${k}"][data-v="${CSS.escape(C[k])}"]`);
if (again) again.focus();
});
render();
}
// ---- Inside plugin windows ---------------------------------------------------------------------------
// ~40 window contexts are folded into a few top-level groups; each group lists its windows as sections,
// with chips to show one, and one search box filters every window at once.
{
const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
const has = (s, ...w) => w.every((x) => s.includes(x));
const human = (a) => String(a || "").replace(/_/g, " ").replace(/^function$/, "custom function").trim();
const picker = obj(D.picker), blink = obj(D.blink), flash = obj(D.flash);
const explorer = arr(D.explorer);
// Top-level groups, in tab order. A window context goes to the first group whose test matches.
const WGROUPS = [
{ id: "picker", name: "Picker", src: "snacks", led: "var(--h6)", test: (c) => c.startsWith("snacks picker") },
{ id: "explorer", name: "Explorer", src: "snacks", led: "var(--h12)", test: (c) => has(c, "explorer") },
{ id: "complete", name: "Completion", src: "blink.cmp", led: "var(--h4)", test: (c) => has(c, "completion") || has(c, "blink") },
{ id: "jump", name: "Flash & Leap", src: "flash", led: "var(--h1)", test: (c) => has(c, "flash") || has(c, "leap") || /\bafter [ft]\b/.test(c) },
{ id: "git", name: "Git", src: "gitsigns", led: "var(--h11)", test: (c) => /\b(git|gitsigns|github|forgejo|pr)\b/.test(c) },
{ id: "notes", name: "Notes", src: "obsidian.nvim", led: "var(--h5)", test: (c) => /\b(obsidian|markdown|haunt)\b/.test(c) },
{ id: "term", name: "Terminal & AI", src: "sidekick", led: "var(--h9)", test: (c) => /\b(terminal|sidekick|kitty|scrollback|nes)\b/.test(c) },
{ id: "other", name: "Other", src: "config", led: "var(--h13)", test: () => true },
];
const groupOf = (ctx) => WGROUPS.find((g) => g.test(norm(ctx))) || WGROUPS[WGROUPS.length - 1];
// Items: { keys (HTML), desc, chain, modes, note (HTML), hay (search text) }.
const itemModes = (it) => (Array.isArray(it.mode) ? it.mode : it.mode ? [it.mode] : []);
const hayOf = (toks, ...rest) => [toks.join(""), toks.map(humanTok).join(" "), toks.map(glyphTok).join(" "), ...rest.map(stripTags)].join(" ").toLowerCase();
function dataItem(it, curMap) {
const toks = canonKeys(it.lhs);
const c = curMap.get(toks.join(""));
if (c) c.used = true;
const actions = arr(it.actions).length ? arr(it.actions) : [it.action];
const chain = actions.map(human).filter(Boolean).join(" → ");
const desc = (c && c.d) || it.desc || chain || "(action)";
const modes = itemModes(it).length ? itemModes(it) : c ? c.m : [];
return { keys: tokCaps(toks), desc, chain: chain !== desc ? chain : "", modes, note: (c && c.n) || "", hay: hayOf(toks, desc, chain, c ? c.n : "") };
}
const curItem = (x) => ({ keys: keysHTML(x), desc: x.d, chain: "", modes: x.m, note: x.n, hay: hayOf(x.tok, x.raw, x.d, x.n) });
const byLhs = (a, b) => String(a.lhs).toLowerCase().localeCompare(String(b.lhs).toLowerCase());
// Sections from the live data (picker, explorer, blink), with curated notes merged in by key.
const SECTIONS = [];
const sec = (s) => { s.items = s.items || []; SECTIONS.push(s); return s; };
const dataSecs = [];
if (arr(picker.input).length || arr(picker.list).length || arr(picker.preview).length) {
const lede = "Any Snacks picker (files, grep, buffers…). Your config adds <kbd>s</kbd> and <kbd>Alt</kbd><kbd>s</kbd> (flash) and <kbd>Alt</kbd><kbd>a</kbd> (send to Sidekick) in the input.";
dataSecs.push({ g: "picker", name: "Input box", src: "snacks", data: arr(picker.input), take: (c) => c === "snacks picker input" || c === "snacks picker", lede });
dataSecs.push({ g: "picker", name: "Result list", src: "snacks", data: arr(picker.list), take: (c) => c === "snacks picker list" });
dataSecs.push({ g: "picker", name: "Preview", src: "snacks", data: arr(picker.preview), take: (c) => c === "snacks picker preview" });
}
if (explorer.length) dataSecs.push({ g: "explorer", name: "Explorer list", src: "snacks", data: explorer, take: (c) => has(c, "explorer"),
lede: "The file explorer (<kbd>Space</kbd><kbd>e</kbd>): its list window, including the picker list keys it inherits." });
const blinkLede = "blink.cmp keys while completing. Each key tries its actions left to right; <i>fallback</i> runs whatever the key would do without blink.";
if (arr(blink.default).length) dataSecs.push({ g: "complete", name: "Insert mode", src: "blink.cmp", data: arr(blink.default), take: (c) => has(c, "completion") && has(c, "insert"), lede: blinkLede });
if (arr(blink.cmdline).length) dataSecs.push({ g: "complete", name: "Command line", src: "blink.cmp", data: arr(blink.cmdline), take: (c) => has(c, "completion") && has(c, "cmdline") });
if (arr(blink.term).length) dataSecs.push({ g: "complete", name: "Terminal", src: "blink.cmp", data: arr(blink.term), take: (c) => has(c, "completion") && has(c, "term") });
// Curated entries with a ctx: merged into a data section, or a section of their own.
const byCtx = groupBy(CUR.filter((x) => x.ctx), (x) => x.ctx);
const ctxSecs = new Map();
for (const [ctx, list] of byCtx) {
const c = norm(ctx);
const ds = dataSecs.find((d) => d.take(c));
if (ds) { (ds.cur || (ds.cur = [])).push(...list); continue; }
ctxSecs.set(ctx, list);
}
for (const d of dataSecs) {
const curMap = new Map((d.cur || []).map((x) => [x.kb || x.kf, x]));
const items = d.data.slice().sort(byLhs).map((it) => dataItem(it, curMap));
items.push(...(d.cur || []).filter((x) => !x.used).map(curItem));
for (const x of d.cur || []) delete x.used;
sec({ g: d.g, name: d.name, src: d.src, lede: d.lede, items });
}
// Flash's enhanced f/t (from the flash config): a section with its own explanation.
if (arr(flash.char_keys).length) {
const keys = arr(flash.char_keys);
const fl = (k) => LIVE.find((x) => x.s === "flash" && x.kb === k && !x.ctx);
sec({ g: "jump", name: "Flash f / t", src: "flash",
lede: `Flash replaces the built-in character motions. ${flash.multi_line === false ? "<b>Current line only</b> (<code>multi_line = false</code>), like stock Vim." : "Searches across lines."}
${flash.jump_labels ? "Shows jump labels." : "No jump labels:"} after <kbd>f</kbd>/<kbd>t</kbd>, press the same key again for the next match and its capital (<kbd>F</kbd>/<kbd>T</kbd>) for the previous one; <kbd>;</kbd> and <kbd>,</kbd> also work.`,
items: keys.map((k) => {
const x = fl(k), b = BUILT.find((y) => y.kb === k && y.m.includes("n"));
const desc = x && !UNHELPFUL.test(x.d) ? x.d : b ? b.d : "";
return { keys: tokCaps(canonKeys(k)), desc, chain: x ? stripTags(fileLine(x)) : "", modes: x ? x.m : [], note: "", hay: hayOf(canonKeys(k), desc, "flash") };
}) });
}
for (const [ctx, list] of [...ctxSecs].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))) {
const g = groupOf(ctx);
// Inside the Picker group "Snacks picker · git_log" reads better as "git_log".
const name = g.id === "picker" ? ctx.replace(/^Snacks picker\s*·\s*/i, "") : ctx;
sec({ g: g.id, name, src: list[0].s, items: list.map(curItem) });
}
SECTIONS.forEach((s, i) => { s.id = `w-${i}`; s.hayName = norm(s.name); });
const GROUPS_W = WGROUPS.map((g) => ({ ...g, secs: SECTIONS.filter((s) => s.g === g.id) })).filter((g) => g.secs.length);
// Big groups (the picker has ~150 keys over 30 windows) open on their first window, small ones show all.
const firstSub = (g) => (g && g.secs.length > 1 && g.secs.reduce((k, s) => k + s.items.length, 0) > 60 ? g.secs[0].id : "all");
const W = { g: 0, sub: firstSub(GROUPS_W[0]), q: "" };
const itemHTML = (it) => `<div class="witem">${it.keys}<div class="act"><span>${esc(it.desc)}${it.modes.length ? `<span class="ms">${modeBadges(it.modes)}</span>` : ""}</span>
${it.chain ? `<span class="chain">${esc(it.chain)}</span>` : ""}${it.note ? `<span class="rn">${it.note}</span>` : ""}</div></div>`;
const secHTML = (s, items, showGroup) => `<div class="wgroup" id="${s.id}"><h3>${esc(s.name)} ${srcBadge(s.src)}${showGroup ? `<span class="ctx">${esc(WGROUPS.find((g) => g.id === s.g).name)}</span>` : ""} <span class="count">${items.length}</span></h3>
${s.lede && !W.q ? `<p class="lede">${s.lede}</p>` : ""}<div class="wlist">${items.map(itemHTML).join("")}</div></div>`;
function matches(s) {
if (!W.q) return s.items;
const words = W.q.split(/\s+/).filter(Boolean);
const nameHit = words.every((w) => s.hayName.includes(w));
return nameHit ? s.items : s.items.filter((it) => words.every((w) => it.hay.includes(w)));
}
function draw() {
if (!GROUPS_W.length) { $("wpanel").innerHTML = `<p class="muted">No plugin window keys in the data.</p>`; return; }
const total = SECTIONS.reduce((n, s) => n + s.items.length, 0);
if (W.q) {
// Search: every group, only the matching keys; tabs show how many each group has.
const hits = SECTIONS.map((s) => [s, matches(s)]).filter(([, it]) => it.length);
const n = hits.reduce((k, [, it]) => k + it.length, 0);
for (const t of $("wtabs").children) {
const g = GROUPS_W[+t.dataset.i];
t.querySelector("span").textContent = hits.filter(([s]) => s.g === g.id).reduce((k, [, it]) => k + it.length, 0);
t.setAttribute("aria-selected", "false");
}
$("wsubs").innerHTML = "";
$("wcount").textContent = `${n} / ${total}`;
$("wpanel").removeAttribute("aria-labelledby");
$("wpanel").innerHTML = hits.length ? hits.map(([s, it]) => secHTML(s, it, true)).join("") : `<p class="empty">No key in a plugin window matches “${esc(W.q)}”.</p>`;
return;
}
const g = GROUPS_W[W.g];
for (const t of $("wtabs").children) {
const tg = GROUPS_W[+t.dataset.i];
t.querySelector("span").textContent = tg.secs.reduce((k, s) => k + s.items.length, 0);
t.setAttribute("aria-selected", String(+t.dataset.i === W.g));
}
$("wcount").textContent = `${total} keys`;
$("wsubs").innerHTML = g.secs.length > 1 ? `<button type="button" class="chip" data-v="all" aria-pressed="${W.sub === "all"}">All<span>${g.secs.reduce((k, s) => k + s.items.length, 0)}</span></button>`
+ g.secs.map((s) => `<button type="button" class="chip" data-v="${s.id}" aria-pressed="${W.sub === s.id}">${esc(s.name)}<span>${s.items.length}</span></button>`).join("") : "";
$("wpanel").setAttribute("aria-labelledby", `wtab-${g.id}`);
$("wpanel").innerHTML = g.secs.filter((s) => W.sub === "all" || s.id === W.sub).map((s) => secHTML(s, s.items, false)).join("");
}
$("wtabs").innerHTML = GROUPS_W.map((g, i) => `<button type="button" class="tab" role="tab" id="wtab-${g.id}" style="--led:${g.led}" aria-selected="${i === W.g}" aria-controls="wpanel" data-i="${i}"><i></i>${esc(g.name)} <span></span></button>`).join("");
const pick = (i) => { W.g = i; W.sub = firstSub(GROUPS_W[i]); if (W.q) { W.q = ""; $("wq").value = ""; } draw(); };
$("wtabs").addEventListener("click", (e) => { const b = e.target.closest(".tab"); if (b) pick(+b.dataset.i); });
tabArrows($("wtabs"), (t) => pick(+t.dataset.i));
$("wsubs").addEventListener("click", (e) => { const b = e.target.closest(".chip"); if (b) { W.sub = b.dataset.v; draw(); } });
let wFrame = 0;
$("wq").addEventListener("input", () => { cancelAnimationFrame(wFrame); wFrame = requestAnimationFrame(() => { W.q = $("wq").value.trim().toLowerCase(); draw(); }); });
$("wq").addEventListener("keydown", (e) => { if (e.key === "Escape") { $("wq").value = ""; W.q = ""; draw(); } });
draw();
}
// ---- Commands -----------------------------------------------------------------------------------------
{
let csrc = "all";
const counts = new Map();
for (const x of CMDS) counts.set(x.s, (counts.get(x.s) || 0) + 1);
const srcs = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
$("cchips").innerHTML = `<button type="button" class="chip" data-v="all" aria-pressed="true">All<span>${CMDS.length}</span></button>`
+ srcs.map(([s, n]) => `<button type="button" class="chip" data-v="${esc(s)}" aria-pressed="false"><i style="--dot:var(--s-${srcKind(s)})"></i>${esc(srcLabel(s))}<span>${n}</span></button>`).join("");
function draw() {
const q = $("cq").value.trim().toLowerCase().replace(/^:/, "");
const words = q.split(/\s+/).filter(Boolean);
const list = CMDS.filter((x) => (csrc === "all" || x.s === csrc) && words.every((w) => x.hay.includes(w)))
.sort((a, b) => (q ? (b.exFull.toLowerCase().startsWith(q) - a.exFull.toLowerCase().startsWith(q)) : 0) || a.exFull.localeCompare(b.exFull));
$("ccount").textContent = `${list.length} / ${CMDS.length}`;
$("clist").innerHTML = list.length ? list.map((x) => `<div class="crow2"><div class="cn">${esc(x.raw)}</div><div>${esc(x.d || "(no description)")}
<div class="meta">${srcBadge(x.s)}${x.ctx ? `<span class="ctx">${esc(x.ctx)} buffers</span>` : ""}${x.ref.na && x.ref.na !== "0" ? `<span>args ${esc(x.ref.na)}</span>` : ""}${x.ref.r ? "<span>range</span>" : ""}${fileLine(x)}</div></div></div>`).join("")
: `<p class="empty">No command matches.</p>`;
}
$("cq").addEventListener("input", draw);
$("cq").addEventListener("keydown", (e) => { if (e.key === "Escape") { $("cq").value = ""; draw(); } });
$("cchips").addEventListener("click", (e) => {
const b = e.target.closest(".chip");
if (!b) return;
csrc = b.dataset.v;
for (const c of $("cchips").children) c.setAttribute("aria-pressed", String(c === b));
draw();
});
draw();
}
drawKB();
</script>
</body>
</html>