Powers the Purrvim game on Games. Embed it on any page with a games.purrvim() expression.
Purrvim is a small vim emulator (Normal, Insert, Visual and command-line modes, counts, operators with motions and text objects, registers, undo and redo, . repeat, search, :s, :set nu / :set rnu) wrapped in seventeen levels. Progress and best scores are kept in the visitor's browser when it allows storage, and nowhere else.
The Lua below reads the widget's markup and script from the html and js blocks further down this page, so each stays plain, highlighted code.
-- priority: 10
games = games or {}
-- Returns the body of the first ```<lang> block on a page.
function games.block(page, lang)
local text = space.readPage(page)
local open = "```" .. lang .. "\n"
local s = string.find(text, open, 1, true)
if not s then error("No " .. lang .. " block on " .. page) end
local from = s + #open
local e = string.find(text, "\n```", from, true)
return string.sub(text, from, e - 1)
end
function games.purrvim()
local page = "Library/Games/Purrvim"
return widget.sandbox {
markdown = "Purrvim: a tiny vim school for cats (interactive widget, see Library/Games/Purrvim)",
html = games.block(page, "html"),
script = games.block(page, "js"),
}
end
<style>
html, body { margin: 0; padding: 0; background: transparent; }
* { box-sizing: border-box; }
:root {
--base: #1e1e2e; --mantle: #181825; --crust: #11111b; --text: #cdd6f4; --sub: #a6adc8; --over: #6c7086;
--s0: #313244; --s1: #45475a; --pink: #f5c2e7; --mauve: #cba6f7; --red: #f38ba8; --peach: #fab387;
--yellow: #f9e2af; --green: #a6e3a1; --teal: #94e2d5; --blue: #89b4fa; --lav: #b4befe; --ink: #11111b;
}
html[data-theme="light"] {
--base: #eff1f5; --mantle: #e6e9ef; --crust: #dce0e8; --text: #4c4f69; --sub: #6c6f85; --over: #9ca0b0;
--s0: #ccd0da; --s1: #bcc0cc; --pink: #ea76cb; --mauve: #8839ef; --red: #d20f39; --peach: #fe640b;
--yellow: #df8e1d; --green: #40a02b; --teal: #179299; --blue: #1e66f5; --lav: #7287fd; --ink: #eff1f5;
}
body { font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--text); }
.pv { background: var(--base); border: 1px solid var(--s0); border-radius: 16px; overflow: hidden; outline: none;
margin: 4px 2px 10px; box-shadow: 0 10px 30px -18px rgba(0,0,0,0.55); }
.pv:focus-visible { box-shadow: 0 0 0 2px var(--mauve); }
.mono, .buf, .status, .cmd, kbd { font-family: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; }
.top { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--mantle); border-bottom: 1px solid var(--s0); }
.brand { font-weight: 700; letter-spacing: 0.02em; color: var(--pink); white-space: nowrap; }
.brand span { color: var(--sub); font-weight: 500; }
.dots { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; justify-content: flex-end; }
.dot { width: 22px; height: 22px; border-radius: 7px; border: 1px solid var(--s1); background: transparent; color: var(--sub);
font: 600 10px/1 ui-monospace, Menlo, monospace; cursor: pointer; padding: 0; }
.dot:hover { border-color: var(--mauve); color: var(--text); }
.dot.s1 { background: color-mix(in srgb, var(--yellow) 25%, transparent); }
.dot.s2 { background: color-mix(in srgb, var(--yellow) 50%, transparent); color: var(--text); }
.dot.s3 { background: var(--yellow); color: var(--ink); border-color: transparent; }
.dot.on { outline: 2px solid var(--mauve); outline-offset: 1px; }
.lesson { display: flex; gap: 12px; padding: 12px 14px 10px; align-items: flex-start; }
.cat { position: relative; flex: none; width: 76px; font: 12px/1.15 ui-monospace, Menlo, monospace; color: var(--peach);
white-space: pre; text-align: center; padding-top: 2px; }
.cat .react { position: absolute; left: 50%; top: -6px; transform: translate(-50%, -100%); white-space: nowrap;
font: 600 11px system-ui, sans-serif; color: var(--ink); background: var(--pink); padding: 2px 7px;
border-radius: 999px; opacity: 0; transition: opacity 0.2s, transform 0.2s; pointer-events: none; z-index: 3; }
.cat .react.show { opacity: 1; transform: translate(-50%, -115%); }
.bubble { position: relative; flex: 1; background: var(--mantle); border: 1px solid var(--s0); border-radius: 12px; padding: 9px 12px; }
.bubble::before { content: ""; position: absolute; left: -7px; top: 14px; width: 12px; height: 12px; background: var(--mantle);
border-left: 1px solid var(--s0); border-bottom: 1px solid var(--s0); transform: rotate(45deg); }
.lvl { font-weight: 700; margin-bottom: 3px; }
.lvl .n { color: var(--mauve); }
.chips { display: inline-flex; gap: 4px; margin-left: 6px; vertical-align: 1px; }
.chips kbd { color: var(--blue); }
kbd { font-size: 11.5px; padding: 0 5px; border-radius: 5px; border: 1px solid var(--s1); border-bottom-width: 2px;
background: var(--base); color: var(--text); white-space: nowrap; }
.hint { margin-top: 6px; color: var(--sub); font-size: 12px; }
.editor { position: relative; margin: 0 12px; border: 1px solid var(--s0); border-radius: 10px; overflow: hidden; background: var(--crust); }
.buf { padding: 10px 0 8px; font-size: 14px; line-height: 1.75; min-height: 120px; overflow-x: auto; }
.ln { display: flex; padding: 0 12px; white-space: pre; }
.ln.cl { background: color-mix(in srgb, var(--s0) 45%, transparent); }
.gut { color: var(--over); padding-right: 10px; text-align: right; user-select: none; }
.gut.cur { color: var(--yellow); }
.tilde { color: var(--blue); opacity: 0.55; }
.tx span { position: relative; }
.cn { background: var(--pink); color: var(--ink); border-radius: 2px; animation: blink 1.1s steps(1) infinite; }
.cn.dim { background: var(--s1); color: var(--text); animation: none; }
.ci { box-shadow: inset 2px 0 0 var(--green); }
.vs { background: color-mix(in srgb, var(--mauve) 35%, transparent); }
.vs.cn { background: var(--pink); }
.hl { background: color-mix(in srgb, var(--yellow) 35%, transparent); }
.fish { background: color-mix(in srgb, var(--peach) 40%, transparent); border-radius: 3px; }
.fish::after { content: "🐟"; position: absolute; left: 50%; top: -0.95em; transform: translateX(-50%); font-size: 11px;
line-height: 1; animation: bob 1.2s ease-in-out infinite; pointer-events: none; }
.fish.cn { background: var(--pink); }
@keyframes bob { 0%, 100% { transform: translate(-50%, 0); } 50% { transform: translate(-50%, -3px); } }
@keyframes blink { 50% { opacity: 0.55; } }
.status { display: flex; gap: 0; align-items: stretch; font-size: 12px; background: var(--mantle); border-top: 1px solid var(--s0); }
.status > span { padding: 3px 10px; white-space: nowrap; }
.mode { font-weight: 800; color: var(--ink); letter-spacing: 0.04em; }
.m-normal { background: var(--blue); } .m-insert { background: var(--green); } .m-visual { background: var(--mauve); } .m-command { background: var(--peach); }
.sl-file { color: var(--text); background: var(--s0); }
.sl-grow { flex: 1; }
.showcmd { color: var(--yellow); }
.sl-keys { color: var(--sub); }
.sl-pos { color: var(--ink); background: var(--lav); font-weight: 700; }
.cmd { min-height: 26px; padding: 4px 12px; font-size: 13px; color: var(--text); white-space: pre; overflow: hidden; text-overflow: ellipsis; }
.cmd.err { color: var(--red); }
.cmd .cc { background: var(--text); color: var(--crust); }
.goal { margin: 10px 12px 0; border: 1px dashed var(--s1); border-radius: 10px; padding: 8px 12px; }
.goal h4 { margin: 0 0 4px; font: 700 11px system-ui, sans-serif; letter-spacing: 0.08em; text-transform: uppercase; color: var(--sub); }
.goal .g { font: 13px/1.6 ui-monospace, Menlo, monospace; white-space: pre; color: var(--sub); }
.goal .g.ok { color: var(--green); }
.goal .g::before { content: "· "; color: var(--over); }
.goal .g.ok::before { content: "✓ "; color: var(--green); }
.foot { display: flex; gap: 8px; align-items: center; padding: 10px 12px 12px; flex-wrap: wrap; }
.btn { appearance: none; border: 1px solid var(--s1); background: var(--mantle); color: var(--text); font: 600 12px system-ui, sans-serif;
padding: 5px 10px; border-radius: 9px; cursor: pointer; }
.btn:hover { border-color: var(--mauve); }
.foot .grow { flex: 1; }
.foot .note { color: var(--sub); font-size: 12px; }
.overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px;
background: color-mix(in srgb, var(--crust) 82%, transparent); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
text-align: center; padding: 12px; z-index: 5; }
.overlay.click { cursor: pointer; }
.overlay .big { font-size: 17px; font-weight: 700; }
.overlay .stars { font-size: 30px; letter-spacing: 6px; color: var(--yellow); }
.overlay .stars .off { color: var(--s1); }
.overlay .sub { color: var(--sub); }
.hidden { display: none !important; }
</style>
<div class="pv" id="root" tabindex="0">
<div class="top">
<div class="brand">purrvim <span>· a vim school for cats</span></div>
<div class="dots" id="dots"></div>
</div>
<div class="lesson">
<div class="cat" id="cat"><div class="react" id="react"></div><div id="face"></div></div>
<div class="bubble">
<div class="lvl" id="lvl"></div>
<div id="say"></div>
<div class="hint hidden" id="hint"></div>
</div>
</div>
<div class="editor" id="editor">
<div class="buf" id="buf"></div>
<div class="status" id="status"></div>
<div class="cmd" id="cmd"></div>
<div class="overlay click" id="sleep"><div class="big">Click here to wake Schro</div><div class="sub">then use your keyboard. Schro does not believe in arrow keys.</div></div>
<div class="overlay hidden" id="win"></div>
</div>
<div class="goal hidden" id="goal"></div>
<div class="foot">
<button class="btn" id="bHint">Hint</button>
<button class="btn" id="bReset">Restart level</button>
<span class="grow"></span>
<span class="note" id="note"></span>
<button class="btn" id="bPrev">← Prev</button>
<button class="btn" id="bNext">Next →</button>
</div>
</div>
(() => {
"use strict";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
const kbdify = (s) => esc(s).replace(/`([^`]+)`/g, (m, k) => "<kbd>" + k + "</kbd>");
const T = (r, s, off) => ({ r: r, s: s, off: off || 0 });
// ---------- Levels ----------
const LEVELS = [
{ title: "Paws on the home row", keys: "h j k l",
say: "Hi, I'm Schro! In Normal mode, keys move instead of typing: `h` left, `j` down, `k` up, `l` right. Walk me to each fish.",
hint: "`l` six times, `j`, then `jj` and `hhhh`, then `lllll`, then `k` and `l`.",
lines: ["schro naps in a box.", "the box sits on a mat.", "the mat is very warm.", "a fish waits nearby."],
cur: [0, 0], targets: [T(0, "naps"), T(1, "x"), T(3, "fish"), T(3, "waits"), T(2, "is")], par: 20 },
{ title: "Count your paw-steps", keys: "6j 17l 3k",
say: "Put a number in front of a motion to repeat it: `6j` goes down six lines, `17l` right seventeen columns. Fewer keys, more naps.",
hint: "`6j` `17l`, then `3k` `9h`, then `j` `22l`.",
lines: ["1 one fish,", "2 two fish,", "3 red fish,", "4 blue fish.", "5 this one has a little star.", "6 this one has a little car.", "7 what a lot of fish there are!"],
cur: [0, 0], targets: [T(6, "fish"), T(3, "blue"), T(4, "star")], par: 14 },
{ title: "Hop word to word", keys: "w b e",
say: "`w` hops to the start of the next word, `b` back to the previous one, `e` to the end of a word. Punctuation counts as its own word; `W` `B` `E` hop straight over it.",
hint: "`4w`, then `4e`, then `7w`, then `3b`.",
lines: ["the quick brown cat jumps over the lazy dog.", "cats leap; dogs nap. fish swim, birds sing!"],
cur: [0, 0], targets: [T(0, "jumps"), T(0, "lazy", 3), T(1, "nap"), T(1, "leap")], par: 9 },
{ title: "Edge of the line", keys: "0 ^ $",
say: "`$` jumps to the end of the line, `0` to the very first column, and `^` to the first character that isn't a space, which is what you want in indented code.",
hint: "`$`, `^`, `0`, then `2j` `^`, then `$`.",
lines: [" let catnip = fetch(\"garden\");", " if (catnip.ready) {", " nap(9000);", " }"],
cur: [0, 12], targets: [T(0, ";"), T(0, "let"), T(0, 0), T(2, "nap"), T(2, ";")], par: 8 },
{ title: "Leaps and bounds", keys: "G gg 12G :12",
say: "`G` jumps to the last line and `gg` to the first. `12G` (or `:12` then Enter) goes to line 12. Tip: `:set rnu` shows how far away each line is, so a jump like `5k` is easy to read off.",
hint: "`G`, `gg`, `12G`, then `5k`.",
lines: ["01 cats sleep for most of the day.", "02 a group of cats is a clowder.", "03 cats can't taste sweetness.", "04 a cat's nose print is unique.",
"05 cats walk like camels and giraffes.", "06 kittens are born with blue eyes.", "07 cats have 32 muscles in each ear.", "08 a cat can jump six times its length.",
"09 cats spend hours grooming themselves.", "10 the oldest cat on record lived to 38.", "11 cats purr at 25 to 150 hertz.", "12 whiskers are about as wide as the cat.",
"13 cats see well in dim light.", "14 a cat's heart beats twice as fast as ours.", "15 cats have five toes on their front paws.", "16 cats can turn their ears 180 degrees.",
"17 a slow blink means 'i trust you'.", "18 cats knead when they are happy.", "19 a cat's purr can mean it is calming itself.", "20 every cat secretly knows vim."],
cur: [3, 0], targets: [T(19, 0), T(0, 0), T(11, 0), T(6, 0)], par: 9 },
{ title: "Pounce!", keys: "f t F T ; ,",
say: "`f` and a character pounces onto the next one on the line; `t` lands just before it. `F` and `T` pounce backwards. `;` repeats the last pounce and `,` repeats it the other way.",
hint: "`f=` `f3` `f!` `Fp`, then `j` `f:` `;` and `t\"`.",
lines: ["meow(); purr = [1, 2, 3]; hiss!", "const snack = { tuna: \"yes\", kibble: \"meh\" };"],
cur: [0, 0], targets: [T(0, "="), T(0, "3"), T(0, "!"), T(0, "purr"), T(1, "kibble:", 6), T(1, "\"meh\"", -1)], par: 15 },
{ title: "Needle in a haystack", keys: "/ n N *",
say: "`/needle` then Enter searches forward. `n` jumps to the next match and `N` to the previous one. `*` searches for the word under the cursor. Matches stay lit until `:noh`.",
hint: "`/needle` Enter, then `n`, `n`, `N`.",
lines: ["hay hay hay hay needle hay hay", "hay hay hay hay hay hay hay hay", "hay needle hay hay hay hay hay", "hay hay hay hay hay hay hay hay", "hay hay hay hay hay hay needle"],
cur: [0, 0], targets: [T(0, "needle"), T(2, "needle"), T(4, "needle"), T(2, "needle")], par: 12 },
{ title: "Bracket buddies", keys: "%",
say: "`%` jumps between matching brackets: `( )`, `[ ]` and `{ }`. If the cursor isn't on a bracket, it uses the next one on the line.",
hint: "`%`, then `$` `%`, then `k` `%`, then `%`.",
lines: ["if (purr(loud) && (nap[3] > 2)) {", " feed({ tuna: [1, 2] });", "}"],
cur: [0, 3], targets: [T(0, ") {"), T(2, 0), T(1, ");"), T(1, "(")], par: 7 },
{ title: "Snip the typos", keys: "x dw dd",
say: "Time to edit. `x` deletes the character under the cursor, `dw` deletes a word, `dd` deletes the whole line. Make the text match the goal underneath.",
hint: "`ee` then `x`. `3w` then `dw`. `j` then `dd`.",
lines: ["the catt sat on the the mat", "this line is a hairball", "and purred all day long"],
goal: ["the cat sat on the mat", "and purred all day long"], par: 11 },
{ title: "Insert some purrs", keys: "i a A o O Esc",
say: "`i` inserts before the cursor, `a` after it, `A` at the end of the line, `o` opens a new line below (`O` above). Now your keys type text. Press `Esc` to get back to Normal mode.",
hint: "`i` type \"the \" `Esc`, `ww`, `i` type \"took a \" `Esc`, `A.` `Esc`, `o` type \"zzz\" `Esc`.",
lines: ["cat nap"], goal: ["the cat took a nap.", "zzz"], par: 26 },
{ title: "A change of heart", keys: "ci\" ciw C",
say: "`c` deletes and drops you into Insert mode. `cw` changes a word, `ciw` the whole word under the cursor, `ci\"` everything inside the quotes, `C` the rest of the line.",
hint: "`ci\"` type \"happy\" `Esc`. `j` `b` `ciw` type \"cat\" `Esc`. `j` `0` `f:` `w` `C` type \"nap\" `Esc`.",
lines: ["let mood = \"grumpy\";", "const pet = dog;", "// TODO: everything"],
goal: ["let mood = \"happy\";", "const pet = cat;", "// TODO: nap"], par: 30 },
{ title: "Do it again", keys: ". u Ctrl-r",
say: "`.` repeats your last change. Add a `;` to one line with `A;` then `Esc`, and `j.` your way down the rest. Made a mess? `u` undoes and `Ctrl-r` redoes.",
hint: "`A;` `Esc`, then `j.` four times.",
lines: ["let a = 1", "let b = 2", "let c = 3", "let d = 4", "let e = 5"],
goal: ["let a = 1;", "let b = 2;", "let c = 3;", "let d = 4;", "let e = 5;"], par: 11 },
{ title: "Shuffle the to-do list", keys: "dd p P yy",
say: "Deleted text isn't gone: it waits in a register. `p` puts it after the cursor (below, for whole lines) and `P` before. `yy` yanks (copies) a line. `ddp` swaps two lines.",
hint: "`ddp`, then `ddp` again.",
lines: ["3. eat breakfast", "1. wake up", "2. stretch", "4. nap again"],
goal: ["1. wake up", "2. stretch", "3. eat breakfast", "4. nap again"], par: 6 },
{ title: "Seeing things", keys: "v V viw u U ~",
say: "`v` starts Visual mode and `V` selects whole lines. Move to stretch the selection, then act on it: `d` deletes, `y` yanks, `u` lowercases, `U` uppercases, `~` flips case.",
hint: "`j` `Vjj` `d`. Then `j` `2w` `viw` `u`.",
lines: ["keep this line", "delete me", "delete me too", "and me", "keep this one as well", "the word SHOUT should be quiet"],
goal: ["keep this line", "keep this one as well", "the word shout should be quiet"], par: 13 },
{ title: "Find and replace", keys: ":s :%s",
say: "Commands that start with `:` are Ex commands. `:s/old/new/` replaces on the current line; `:%s/old/new/g` replaces every match in the file.",
hint: "`:%s/dog/cat/g` then Enter.",
lines: ["the dog sat by the door.", "a dog's life is a long nap.", "every dog has its day,", "but no dog beats a cat."],
goal: ["the cat sat by the door.", "a cat's life is a long nap.", "every cat has its day,", "but no cat beats a cat."], par: 14 },
{ title: "Inside out", keys: "di( di[ ci\" da(",
say: "Text objects choose what an operator acts on: `i` for inside or `a` for around, then `(` `[` `{` `\"` or `w`. `di(` empties the brackets around the cursor.",
hint: "`f(` `di(`. `j` `f[` `di[`. `j` `ci\"` type \"purr\" `Esc`.",
lines: ["print(remove(these, words))", "tags = [\"old\", \"stale\", \"dusty\"]", "say(\"hello world\")"],
goal: ["print()", "tags = []", "say(\"purr\")"], par: 21 },
{ title: "The final boss", keys: ":wq :q ZZ",
say: "Legend says nobody ever leaves vim. Save and quit with `:wq` or `ZZ`. Quit without saving with `:q` (or `:q!` to throw changes away).",
hint: "`ZZ`, or `:wq` then Enter.",
lines: ["you made it, kitten.", "everyone gets stuck in vim once.", "not you. you know the secret.", "go on. leave."],
cur: [3, 0], exit: true, par: 3 }
];
// ---------- Persistence (optional; the sandbox may forbid storage) ----------
const store = {
get(k, d) { try { const v = localStorage.getItem("purrvim:" + k); return v == null ? d : JSON.parse(v); } catch (e) { return d; } },
set(k, v) { try { localStorage.setItem("purrvim:" + k, JSON.stringify(v)); } catch (e) {} }
};
let best = store.get("best", {});
let levelIdx = Math.min(store.get("level", 0), LEVELS.length - 1);
// ---------- Mascot ----------
const FACES = {
idle: " /\\_/\\\n( o.o )\n > ^ <",
happy: " /\\_/\\\n( ^.^ )\n > ^ <",
wow: " /\\_/\\\n( O.O )\n > ^ <",
huh: " /\\_/\\\n( o.O )\n > ? <",
sad: " /\\_/\\\n( ;.; )\n > ^ <",
sleep: " /\\_/\\\n( -.- )\n > z <",
wink: " /\\_/\\\n( ^.~ )\n > ^ <"
};
let mood = "sleep", reactTimer = null;
function react(text, m) {
mood = m || "happy";
const r = $("react");
r.textContent = text; r.classList.add("show");
clearTimeout(reactTimer);
reactTimer = setTimeout(() => { r.classList.remove("show"); mood = focused ? "idle" : "sleep"; drawFace(); }, 1500);
drawFace();
}
function drawFace() { $("face").textContent = FACES[mood] || FACES.idle; }
const YUM = ["nom!", "purrfect", "*crunch*", "yum!", "mrrp!", "tasty"];
const HUH = ["hm?", "*tilts head*", "mrow?", "that did nothing"];
const pick = (a) => a[Math.floor(Math.random() * a.length)];
// ---------- Editor state ----------
let LV, st, replaying = false, focused = false, hintShown = false;
function resolveTargets(lv) {
return (lv.targets || []).map((t) => {
const c = typeof t.s === "number" ? t.s : lv.lines[t.r].indexOf(t.s) + t.off;
return { r: t.r, c: c };
});
}
function load(i) {
levelIdx = i; store.set("level", i);
LV = LEVELS[i];
st = {
lines: LV.lines.slice(), r: LV.cur ? LV.cur[0] : 0, c: LV.cur ? LV.cur[1] : 0, want: 0,
mode: "normal", keys: [], vis: null, cmd: "", cmdType: ":", msg: "", err: false,
reg: { text: "", line: false }, undo: [], redo: [], last: null, lastFind: null, lastSearch: null, hl: null,
start: null, strokes: 0, tgt: 0, targets: resolveTargets(LV), done: false, exited: false,
nu: false, rnu: false, saved: LV.lines.slice()
};
st.want = st.c;
hintShown = false;
$("hint").classList.add("hidden");
$("win").classList.add("hidden");
render();
}
// ---------- Helpers ----------
const isWs = (ch) => ch === " " || ch === "\t";
function cls(ch, big) { if (ch === undefined || isWs(ch)) return 0; if (big) return 1; return /\w/.test(ch) ? 1 : 2; }
const len = (r) => st.lines[r].length;
function firstNB(r) { const m = /\S/.exec(st.lines[r]); return m ? m.index : Math.max(0, len(r) - 1); }
function indentOf(s) { return (/^\s*/.exec(s) || [""])[0]; }
function cmp(a, b) { return a.r - b.r || a.c - b.c; }
function snap() { return { lines: st.lines.slice(), r: st.r, c: st.c }; }
function same(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; }
function clampN() {
st.r = Math.max(0, Math.min(st.r, st.lines.length - 1));
const max = st.mode === "insert" ? len(st.r) : Math.max(0, len(st.r) - 1);
st.c = Math.max(0, Math.min(st.c, max));
}
function msg(t, err) { st.msg = t; st.err = !!err; }
function buzz() { react(pick(HUH), "huh"); }
function nextPos(p) { let r = p.r, c = p.c + 1; while (r < st.lines.length && c >= st.lines[r].length) { r++; c = 0; } return r < st.lines.length ? { r: r, c: c } : null; }
function prevPos(p) { let r = p.r, c = p.c - 1; while (c < 0) { r--; if (r < 0) return null; c = st.lines[r].length - 1; } return { r: r, c: c }; }
const charAt = (p) => st.lines[p.r][p.c];
function getRange(a, b) {
if (a.r === b.r) return st.lines[a.r].slice(a.c, b.c);
let s = st.lines[a.r].slice(a.c);
for (let r = a.r + 1; r < b.r; r++) s += "\n" + st.lines[r];
return s + "\n" + st.lines[b.r].slice(0, b.c);
}
function delRange(a, b) {
const head = st.lines[a.r].slice(0, a.c), tail = st.lines[b.r].slice(b.c);
st.lines.splice(a.r, b.r - a.r + 1, head + tail);
}
// ---------- Motions ----------
function wordFwd(p, big) {
let r = p.r, c = p.c; const L = st.lines;
if (L[r].length) { const k = cls(L[r][c], big); if (k) while (c < L[r].length && cls(L[r][c], big) === k) c++; }
for (;;) {
if (c >= L[r].length) {
if (r >= L.length - 1) return { r: r, c: Math.max(0, L[r].length - 1), end: true };
r++; c = 0;
if (!L[r].length) return { r: r, c: 0 };
continue;
}
if (isWs(L[r][c])) { c++; continue; }
return { r: r, c: c };
}
}
function wordEnd(p, big) {
let r = p.r, c = p.c; const L = st.lines;
const adv = () => {
if (c + 1 < L[r].length) { c++; return true; }
for (let rr = r + 1; rr < L.length; rr++) if (L[rr].length) { r = rr; c = 0; return true; }
return false;
};
if (!adv()) return { r: r, c: c };
while (isWs(L[r][c])) if (!adv()) return { r: r, c: c };
const k = cls(L[r][c], big);
while (c + 1 < L[r].length && cls(L[r][c + 1], big) === k) c++;
return { r: r, c: c };
}
function wordBack(p, big) {
let r = p.r, c = p.c; const L = st.lines;
const back = () => {
if (c > 0) { c--; return true; }
if (r === 0) return false;
r--; c = Math.max(0, L[r].length - 1); return true;
};
if (!back()) return { r: r, c: c };
while (L[r].length && isWs(L[r][c])) if (!back()) return { r: r, c: c };
if (!L[r].length) return { r: r, c: 0 };
const k = cls(L[r][c], big);
while (c > 0 && cls(L[r][c - 1], big) === k) c--;
return { r: r, c: c };
}
function findChar(kind, ch, n) {
const line = st.lines[st.r];
const fwd = kind === "f" || kind === "t", till = kind === "t" || kind === "T", d = fwd ? 1 : -1;
let c = st.c;
for (let k = 0; k < n; k++) {
let i = c + d;
if (till && line[i] === ch) i += d;
while (i >= 0 && i < line.length && line[i] !== ch) i += d;
if (i < 0 || i >= line.length) return null;
c = till ? i - d : i;
}
return { r: st.r, c: c, type: fwd ? "incl" : "excl" };
}
const PAIRS = { "(": ")", "[": "]", "{": "}", ")": "(", "]": "[", "}": "{" };
function matchBracket() {
const line = st.lines[st.r];
let c = st.c;
while (c < line.length && !PAIRS[line[c]]) c++;
if (c >= line.length) return null;
const ch = line[c], other = PAIRS[ch], fwd = "([{".indexOf(ch) >= 0;
let depth = 0, p = { r: st.r, c: c };
while (p) {
const x = charAt(p);
if (x === ch) depth++;
else if (x === other) { depth--; if (depth === 0) return { r: p.r, c: p.c, type: "incl" }; }
p = fwd ? nextPos(p) : prevPos(p);
}
return null;
}
function searchFrom(pat, dir, from) {
const L = st.lines, N = L.length;
if (dir > 0) {
const i = L[from.r].indexOf(pat, from.c + 1);
if (i >= 0) return { r: from.r, c: i };
for (let k = 1; k <= N; k++) {
const r = (from.r + k) % N, j = L[r].indexOf(pat);
if (j >= 0) return { r: r, c: j, wrapped: from.r + k >= N };
}
} else {
const i = from.c > 0 ? L[from.r].lastIndexOf(pat, from.c - 1) : -1;
if (i >= 0) return { r: from.r, c: i };
for (let k = 1; k <= N; k++) {
const r = (from.r - k + N * 2) % N, j = L[r].lastIndexOf(pat);
if (j >= 0) return { r: r, c: j, wrapped: from.r - k < 0 };
}
}
return null;
}
function doSearch(pat, dir) {
if (!pat) return null;
st.hl = pat;
const res = searchFrom(pat, dir, { r: st.r, c: st.c });
if (!res) { msg("E486: Pattern not found: " + pat, true); return null; }
if (res.wrapped) msg(dir > 0 ? "search hit BOTTOM, continuing at TOP" : "search hit TOP, continuing at BOTTOM", true);
else msg((dir > 0 ? "/" : "?") + pat);
return { r: res.r, c: res.c, type: "excl" };
}
// Returns {r, c, type: "excl" | "incl" | "line", keep?: true (keep column memory), want?: number}
function motion(m, count, hasCount, forOp) {
const n = count || 1, L = st.lines;
let r = st.r, c = st.c;
switch (m.m) {
case "h": case "<BS>": return { r: r, c: Math.max(0, c - n), type: "excl" };
case "l": case " ": return { r: r, c: Math.min(c + n, Math.max(0, len(r) - (forOp ? 0 : 1))), type: "excl" };
case "j": case "<CR>": case "+": r = Math.min(L.length - 1, r + n); return { r: r, c: Math.min(st.want, Math.max(0, len(r) - 1)), type: "line", keep: true };
case "k": case "-": r = Math.max(0, r - n); return { r: r, c: Math.min(st.want, Math.max(0, len(r) - 1)), type: "line", keep: true };
case "0": return { r: r, c: 0, type: "excl" };
case "^": return { r: r, c: firstNB(r), type: "excl" };
case "$": r = Math.min(L.length - 1, r + n - 1); return { r: r, c: Math.max(0, len(r) - 1), type: "incl", want: Infinity };
case "w": case "W": { let p = { r: r, c: c }; for (let k = 0; k < n; k++) p = wordFwd(p, m.m === "W"); if (p.end && forOp) p.c = len(p.r); return { r: p.r, c: p.c, type: "excl" }; }
case "b": case "B": { let p = { r: r, c: c }; for (let k = 0; k < n; k++) p = wordBack(p, m.m === "B"); return { r: p.r, c: p.c, type: "excl" }; }
case "e": case "E": { let p = { r: r, c: c }; for (let k = 0; k < n; k++) p = wordEnd(p, m.m === "E"); return { r: p.r, c: p.c, type: "incl" }; }
case "G": r = hasCount ? Math.min(L.length - 1, Math.max(0, count - 1)) : L.length - 1; return { r: r, c: firstNB(r), type: "line" };
case "gg": r = hasCount ? Math.min(L.length - 1, Math.max(0, count - 1)) : 0; return { r: r, c: firstNB(r), type: "line" };
case "f": case "t": case "F": case "T": st.lastFind = { m: m.m, arg: m.arg }; return findChar(m.m, m.arg, n);
case ";": case ",": {
if (!st.lastFind) return null;
let k = st.lastFind.m;
if (m.m === ",") k = { f: "F", F: "f", t: "T", T: "t" }[k];
return findChar(k, st.lastFind.arg, n);
}
case "%": return matchBracket();
case "n": case "N": {
if (!st.lastSearch) { msg("E35: No previous regular expression", true); return null; }
let res = null;
for (let k = 0; k < n; k++) {
res = doSearch(st.lastSearch.pat, m.m === "n" ? st.lastSearch.dir : -st.lastSearch.dir);
if (!res) return null;
st.r = res.r; st.c = res.c;
}
st.r = r; st.c = c;
return res;
}
case "*": case "#": {
const o = wordObj(false, false);
if (!o) return null;
const pat = getRange(o.a, o.b);
if (!pat.trim()) return null;
st.lastSearch = { pat: pat, dir: m.m === "*" ? 1 : -1 };
return doSearch(pat, st.lastSearch.dir);
}
case "}": { for (let k = 0; k < n; k++) { r++; while (r < L.length - 1 && L[r].trim() !== "") r++; } r = Math.min(r, L.length - 1); return { r: r, c: L[r].trim() === "" ? 0 : Math.max(0, len(r) - 1), type: "excl" }; }
case "{": { for (let k = 0; k < n; k++) { r--; while (r > 0 && L[r].trim() !== "") r--; } r = Math.max(r, 0); return { r: r, c: 0, type: "excl" }; }
}
return null;
}
// ---------- Text objects ----------
function wordObj(big, around) {
const line = st.lines[st.r];
if (!line.length) return null;
const c = Math.min(st.c, line.length - 1), k = cls(line[c], big);
let s = c, e = c;
while (s > 0 && cls(line[s - 1], big) === k) s--;
while (e + 1 < line.length && cls(line[e + 1], big) === k) e++;
if (around && k !== 0) {
if (e + 1 < line.length && isWs(line[e + 1])) { while (e + 1 < line.length && isWs(line[e + 1])) e++; }
else { while (s > 0 && isWs(line[s - 1])) s--; }
}
return { a: { r: st.r, c: s }, b: { r: st.r, c: e + 1 } };
}
function quoteObj(q, around) {
const line = st.lines[st.r], idx = [];
for (let i = 0; i < line.length; i++) if (line[i] === q && line[i - 1] !== "\\") idx.push(i);
let pair = null;
for (let i = 0; i + 1 < idx.length; i += 2) if (st.c >= idx[i] && st.c <= idx[i + 1]) { pair = [idx[i], idx[i + 1]]; break; }
if (!pair) for (let i = 0; i + 1 < idx.length; i += 2) if (idx[i] > st.c) { pair = [idx[i], idx[i + 1]]; break; }
if (!pair) return null;
return around ? { a: { r: st.r, c: pair[0] }, b: { r: st.r, c: pair[1] + 1 } }
: { a: { r: st.r, c: pair[0] + 1 }, b: { r: st.r, c: pair[1] } };
}
function bracketObj(open, close, around) {
let p = { r: st.r, c: Math.min(st.c, Math.max(0, len(st.r) - 1)) }, o = null, depth = 0;
if (charAt(p) === open) o = p;
else {
if (charAt(p) === close) p = prevPos(p);
while (p) {
const x = charAt(p);
if (x === close) depth++;
else if (x === open) { if (depth === 0) { o = p; break; } depth--; }
p = prevPos(p);
}
}
if (!o) return null;
let q = nextPos(o), cl = null; depth = 0;
while (q) {
const x = charAt(q);
if (x === open) depth++;
else if (x === close) { if (depth === 0) { cl = q; break; } depth--; }
q = nextPos(q);
}
if (!cl) return null;
return around ? { a: o, b: { r: cl.r, c: cl.c + 1 } } : { a: { r: o.r, c: o.c + 1 }, b: cl };
}
function textObj(obj) {
const around = obj[0] === "a", ch = obj[1];
if (ch === "w" || ch === "W") return wordObj(ch === "W", around);
if (ch === "\"" || ch === "'" || ch === "`") return quoteObj(ch, around);
if (ch === "(" || ch === ")" || ch === "b") return bracketObj("(", ")", around);
if (ch === "[" || ch === "]") return bracketObj("[", "]", around);
if (ch === "{" || ch === "}" || ch === "B") return bracketObj("{", "}", around);
if (ch === "<" || ch === ">") return bracketObj("<", ">", around);
return null;
}
// ---------- Operators ----------
function opLines(op, r1, r2) {
const chunk = st.lines.slice(r1, r2 + 1), n = chunk.length;
if (op === "y") { st.reg = { text: chunk.join("\n"), line: true }; st.r = r1; if (n > 2) msg(n + " lines yanked"); return; }
if (op === "d") {
st.reg = { text: chunk.join("\n"), line: true };
st.lines.splice(r1, n);
if (!st.lines.length) st.lines = [""];
st.r = Math.min(r1, st.lines.length - 1); st.c = firstNB(st.r); st.want = st.c;
if (n > 2) msg(n + " fewer lines");
return;
}
if (op === "c") {
st.reg = { text: chunk.join("\n"), line: true };
const ind = indentOf(st.lines[r1]);
st.lines.splice(r1, n, ind);
st.r = r1; st.c = ind.length; enterInsert();
return;
}
if (op === ">" || op === "<") {
for (let r = r1; r <= r2; r++) {
if (op === ">") { if (st.lines[r].length) st.lines[r] = " " + st.lines[r]; }
else st.lines[r] = st.lines[r].replace(/^ {1,2}/, "");
}
st.r = r1; st.c = firstNB(r1); st.want = st.c;
}
}
function opChars(op, a, b) {
if (op === ">" || op === "<") return opLines(op, a.r, b.r);
const text = getRange(a, b);
if (op === "y") { st.reg = { text: text, line: false }; st.r = a.r; st.c = a.c; return; }
st.reg = { text: text, line: false };
delRange(a, b);
st.r = a.r; st.c = a.c;
if (op === "c") enterInsert(); else clampN();
st.want = st.c;
}
function execOp(p) {
const op = p.op, n = p.count || 1;
if (p.lines) return opLines(op, st.r, Math.min(st.lines.length - 1, st.r + n - 1));
if (p.obj) { const o = textObj(p.obj); if (!o) return buzz(); return opChars(op, o.a, o.b); }
let m = p.motion;
const here = st.lines[st.r][st.c];
if (op === "c" && (m.m === "w" || m.m === "W") && here !== undefined && !isWs(here)) {
const big = m.m === "W", nx = st.lines[st.r][st.c + 1];
if (n === 1 && (nx === undefined || cls(nx, big) !== cls(here, big))) return opChars("c", { r: st.r, c: st.c }, { r: st.r, c: st.c + 1 });
m = { m: big ? "E" : "e" };
}
const res = motion(m, p.count, p.hasCount, true);
if (!res) return buzz();
if (res.type === "line") return opLines(op, Math.min(st.r, res.r), Math.max(st.r, res.r));
let a = { r: st.r, c: st.c }, b = { r: res.r, c: res.c };
if (cmp(b, a) < 0) { const t = a; a = b; b = t; }
if ((m.m === "w" || m.m === "W") && b.r > a.r && b.c <= firstNB(b.r)) b = { r: b.r - 1, c: len(b.r - 1) };
if (res.type === "incl") b = { r: b.r, c: b.c + 1 };
opChars(op, a, b);
}
// ---------- Normal-mode commands ----------
function enterInsert() { st.mode = "insert"; msg("-- INSERT --"); }
function put(after, n) {
const R = st.reg;
if (!R.text && !R.line) { msg("E353: Nothing in register \"", true); return buzz(); }
if (R.line) {
let all = []; const ls = R.text.split("\n");
for (let k = 0; k < n; k++) all = all.concat(ls);
const at = after ? st.r + 1 : st.r;
st.lines.splice(at, 0, ...all);
st.r = at; st.c = firstNB(at);
} else {
const txt = R.text.repeat(n), line = st.lines[st.r];
const at = line.length === 0 ? 0 : after ? Math.min(st.c + 1, line.length) : st.c;
const parts = txt.split("\n");
if (parts.length === 1) { st.lines[st.r] = line.slice(0, at) + txt + line.slice(at); st.c = at + txt.length - 1; }
else {
const nl = [line.slice(0, at) + parts[0]].concat(parts.slice(1, -1), [parts[parts.length - 1] + line.slice(at)]);
st.lines.splice(st.r, 1, ...nl); st.c = at;
}
}
st.want = st.c;
}
function restore(s) { st.lines = s.lines.slice(); st.r = s.r; st.c = s.c; clampN(); st.want = st.c; }
function execCmd(p) {
const n = p.count || 1, line = st.lines[st.r];
switch (p.cmd) {
case "x": if (!line.length) return buzz(); return opChars("d", { r: st.r, c: st.c }, { r: st.r, c: Math.min(st.c + n, line.length) });
case "X": if (!st.c) return buzz(); return opChars("d", { r: st.r, c: Math.max(0, st.c - n) }, { r: st.r, c: st.c });
case "D": return opChars("d", { r: st.r, c: st.c }, { r: st.r, c: line.length });
case "C": return opChars("c", { r: st.r, c: st.c }, { r: st.r, c: line.length });
case "s": return opChars("c", { r: st.r, c: st.c }, { r: st.r, c: Math.min(st.c + n, line.length) });
case "S": return opLines("c", st.r, Math.min(st.lines.length - 1, st.r + n - 1));
case "Y": return opLines("y", st.r, Math.min(st.lines.length - 1, st.r + n - 1));
case "r":
if (p.arg.length !== 1 || st.c + n > line.length) return buzz();
st.lines[st.r] = line.slice(0, st.c) + p.arg.repeat(n) + line.slice(st.c + n); st.c += n - 1; return;
case "~": {
if (!line.length) return;
const e = Math.min(line.length, st.c + n);
const flip = line.slice(st.c, e).split("").map((ch) => ch === ch.toUpperCase() ? ch.toLowerCase() : ch.toUpperCase()).join("");
st.lines[st.r] = line.slice(0, st.c) + flip + line.slice(e); st.c = Math.min(e, line.length - 1); st.want = st.c; return;
}
case "J": {
const times = Math.max(1, n - 1);
for (let k = 0; k < times; k++) {
if (st.r + 1 >= st.lines.length) return buzz();
const a = st.lines[st.r].replace(/\s+$/, ""), b = st.lines[st.r + 1].replace(/^\s+/, "");
st.lines.splice(st.r, 2, b ? a + " " + b : a); st.c = a.length;
}
return;
}
case "p": return put(true, n);
case "P": return put(false, n);
case "u":
st.start.skip = true;
for (let k = 0; k < n; k++) {
if (!st.undo.length) { msg("Already at oldest change"); break; }
st.redo.push(snap()); restore(st.undo.pop()); msg("undo: " + (st.undo.length) + " changes left");
}
return;
case "<C-r>":
st.start.skip = true;
for (let k = 0; k < n; k++) {
if (!st.redo.length) { msg("Already at newest change"); break; }
st.undo.push(snap()); restore(st.redo.pop()); msg("redo");
}
return;
case ".": {
if (!st.last) return buzz();
st.start.noRepeat = true;
replaying = true;
for (let k = 0; k < n; k++) { for (const key of st.last) dispatch(key); if (st.mode === "insert") dispatch("<Esc>"); }
replaying = false;
return;
}
case "i": return enterInsert();
case "a": if (line.length) st.c++; return enterInsert();
case "I": st.c = /\S/.test(line) ? firstNB(st.r) : line.length; return enterInsert();
case "A": st.c = line.length; return enterInsert();
case "o": case "O": {
const ind = indentOf(line), at = p.cmd === "o" ? st.r + 1 : st.r;
st.lines.splice(at, 0, ind); st.r = at; st.c = ind.length; return enterInsert();
}
case "v": case "V": st.vis = { type: p.cmd, ar: st.r, ac: st.c }; msg(p.cmd === "v" ? "-- VISUAL --" : "-- VISUAL LINE --"); return;
case ":": case "/": case "?": st.mode = "cmdline"; st.cmdType = p.cmd; st.cmd = ""; st.start.noRepeat = true; return;
case "ZZ": return exCmd(p.arg === "Q" ? "q!" : "x");
}
}
// ---------- Parser ----------
const INC = { inc: 1 }, INV = { inv: 1 }, ESC = { esc: 1 };
const MOTION1 = new Set(["h", "j", "k", "l", "w", "b", "e", "W", "B", "E", "0", "^", "$", "G", ";", ",", "%", "n", "N", "{", "}", "*", "#", "<BS>", " ", "<CR>", "+", "-"]);
const OPS = new Set(["d", "c", "y", ">", "<"]);
const SIMPLE = new Set(["x", "X", "D", "C", "s", "S", "Y", "p", "P", "u", "<C-r>", ".", "i", "a", "I", "A", "o", "O", "v", "V", "J", "~", ":", "/", "?"]);
function readCount(k, i) { let s = ""; while (i < k.length && /^[0-9]$/.test(k[i]) && !(k[i] === "0" && s === "")) s += k[i++]; return [s, i]; }
function parseMotion(k, i) {
const t = k[i];
if (t === "g") { if (i + 1 >= k.length) return INC; if (k[i + 1] === "g") return { m: "gg" }; return INV; }
if (t === "f" || t === "F" || t === "t" || t === "T") { if (i + 1 >= k.length) return INC; if (k[i + 1].length !== 1) return INV; return { m: t, arg: k[i + 1] }; }
if (MOTION1.has(t)) return { m: t };
return INV;
}
function parseNormal(k) {
let [s1, i] = readCount(k, 0);
if (i >= k.length) return INC;
const count = s1 ? parseInt(s1, 10) : 0, t = k[i];
if (t === "<Esc>") return ESC;
if (OPS.has(t)) {
i++;
const [s2, j] = readCount(k, i); i = j;
if (i >= k.length) return INC;
const cnt = (count || 1) * (s2 ? parseInt(s2, 10) : 1), has = !!(count || s2), t2 = k[i];
if (t2 === "<Esc>") return ESC;
if (t2 === t) return { op: t, lines: true, count: cnt };
if (t2 === "i" || t2 === "a") { if (i + 1 >= k.length) return INC; return { op: t, obj: t2 + k[i + 1], count: cnt }; }
const m = parseMotion(k, i);
if (m === INC || m === INV) return m;
return { op: t, motion: m, count: has ? cnt : 0, hasCount: has };
}
if (t === "r") { if (i + 1 >= k.length) return INC; return { cmd: "r", arg: k[i + 1], count: count }; }
if (t === "Z") { if (i + 1 >= k.length) return INC; if (k[i + 1] === "Z" || k[i + 1] === "Q") return { cmd: "ZZ", arg: k[i + 1] }; return INV; }
if (SIMPLE.has(t)) return { cmd: t, count: count };
const m = parseMotion(k, i);
if (m === INC || m === INV) return m;
return { motion: m, count: count, hasCount: !!count };
}
function exec(p) {
if (p.op) return execOp(p);
if (p.motion) {
const res = motion(p.motion, p.count, p.hasCount, false);
if (!res) return buzz();
st.r = res.r; st.c = res.c; clampN();
if (res.want !== undefined) st.want = res.want; else if (!res.keep) st.want = st.c;
return;
}
execCmd(p);
}
function normalKey(k) {
if (st.vis) return visualKey(k);
st.keys.push(k);
const p = parseNormal(st.keys);
if (p === INC) return;
st.keys = [];
if (p === ESC) return;
if (p === INV) return buzz();
exec(p);
}
function visualKey(k) {
st.keys.push(k);
const ks = st.keys;
const [s, i] = readCount(ks, 0);
if (i >= ks.length) return;
const count = s ? parseInt(s, 10) : 0, t = ks[i];
const done = () => { st.keys = []; };
if (t === "<Esc>") { done(); st.vis = null; msg(""); return; }
if (t === "v" || t === "V") { done(); if (st.vis.type === t) { st.vis = null; msg(""); } else { st.vis.type = t; msg(t === "v" ? "-- VISUAL --" : "-- VISUAL LINE --"); } return; }
if (t === "o") { done(); const ar = st.vis.ar, ac = st.vis.ac; st.vis.ar = st.r; st.vis.ac = st.c; st.r = ar; st.c = ac; return; }
if (t === "i" || t === "a") {
if (i + 1 >= ks.length) return;
const o = textObj(t + ks[i + 1]); done();
if (!o) return buzz();
st.vis.ar = o.a.r; st.vis.ac = o.a.c;
const bp = o.b.c > 0 ? { r: o.b.r, c: o.b.c - 1 } : (prevPos(o.b) || o.b);
st.r = bp.r; st.c = bp.c; return;
}
const OPMAP = { d: "d", x: "d", D: "d", X: "d", y: "y", Y: "y", c: "c", s: "c", C: "c", S: "c", ">": ">", "<": "<" };
if (OPMAP[t] || t === "~" || t === "u" || t === "U") {
done();
let a = { r: st.vis.ar, c: st.vis.ac }, b = { r: st.r, c: st.c };
if (cmp(b, a) < 0) { const tmp = a; a = b; b = tmp; }
const lw = st.vis.type === "V" || t === "D" || t === "X" || t === "Y" || t === "C" || t === "S";
st.vis = null; msg("");
if (OPMAP[t]) { if (lw) opLines(OPMAP[t], a.r, b.r); else opChars(OPMAP[t], a, { r: b.r, c: b.c + 1 }); return; }
const f = t === "~" ? (ch) => ch === ch.toUpperCase() ? ch.toLowerCase() : ch.toUpperCase()
: t === "u" ? (ch) => ch.toLowerCase() : (ch) => ch.toUpperCase();
if (lw) { a = { r: a.r, c: 0 }; b = { r: b.r, c: Math.max(0, len(b.r) - 1) }; }
for (let r = a.r; r <= b.r; r++) {
const ln = st.lines[r], s0 = r === a.r ? a.c : 0, e0 = r === b.r ? b.c + 1 : ln.length;
st.lines[r] = ln.slice(0, s0) + ln.slice(s0, e0).split("").map(f).join("") + ln.slice(e0);
}
st.r = a.r; st.c = a.c; clampN(); return;
}
const m = parseMotion(ks, i);
if (m === INC) return;
done();
if (m === INV) return buzz();
const res = motion(m, count, !!count, false);
if (!res) return buzz();
st.r = res.r; st.c = res.c; clampN();
if (res.want !== undefined) st.want = res.want; else if (!res.keep) st.want = st.c;
}
function insertKey(k) {
const line = st.lines[st.r];
if (k === "<Esc>") { st.mode = "normal"; st.c = Math.max(0, st.c - 1); st.want = st.c; msg(""); return; }
if (k === "<BS>") {
if (st.c > 0) { st.lines[st.r] = line.slice(0, st.c - 1) + line.slice(st.c); st.c--; }
else if (st.r > 0) { const prev = st.lines[st.r - 1]; st.lines.splice(st.r - 1, 2, prev + line); st.r--; st.c = prev.length; }
return;
}
if (k === "<CR>") {
const ind = indentOf(line);
st.lines.splice(st.r, 1, line.slice(0, st.c), ind + line.slice(st.c).replace(/^\s+/, ""));
st.r++; st.c = ind.length; return;
}
const ch = k === "<Tab>" ? " " : k;
if (ch.length > 2 || (ch.length === 2 && ch !== " ")) return;
st.lines[st.r] = line.slice(0, st.c) + ch + line.slice(st.c); st.c += ch.length;
}
function cmdKey(k) {
if (k === "<Esc>") { st.mode = "normal"; st.cmd = ""; msg(""); return; }
if (k === "<BS>") { if (!st.cmd) { st.mode = "normal"; msg(""); return; } st.cmd = st.cmd.slice(0, -1); return; }
if (k === "<CR>") {
const s = st.cmd, type = st.cmdType;
st.mode = "normal"; st.cmd = "";
if (type === ":") return exCmd(s);
const dir = type === "/" ? 1 : -1;
const pat = s || (st.lastSearch && st.lastSearch.pat);
if (!pat) return;
st.lastSearch = { pat: pat, dir: dir };
const res = doSearch(pat, dir);
if (res) { st.r = res.r; st.c = res.c; st.want = st.c; }
return;
}
if (k === "<Tab>") return;
if (k.length === 1) st.cmd += k;
}
function exCmd(raw) {
const s = raw.trim();
let m;
if (!s) return;
if (/^(q|q!|qa|qa!|quit|wq|wq!|x|xa|wqa|exit)$/.test(s)) {
if (LV.exit) { st.exited = true; return; }
msg("E37: No write since last change… (Schro says: fish first, then you may leave)", true); react("not yet!", "wink"); return;
}
if (/^w!?$/.test(s)) { st.saved = st.lines.slice(); msg("\"purrvim.txt\" " + st.lines.length + "L written"); return; }
if (/^\d+$/.test(s)) { st.r = Math.min(st.lines.length - 1, Math.max(0, parseInt(s, 10) - 1)); st.c = firstNB(st.r); st.want = st.c; return; }
if (s === "$") { st.r = st.lines.length - 1; st.c = firstNB(st.r); return; }
if (/^e!$/.test(s)) { load(levelIdx); msg("level reloaded"); return; }
if (/^noh(lsearch)?$/.test(s)) { st.hl = null; return; }
if ((m = /^set\s+(no)?(nu|number|rnu|relativenumber)(!)?$/.exec(s))) {
const key = (m[2] === "nu" || m[2] === "number") ? "nu" : "rnu";
st[key] = m[3] ? !st[key] : !m[1]; return;
}
if ((m = /^(%)?s\/((?:\\\/|[^/])*)\/((?:\\\/|[^/])*)(?:\/([gi]*))?$/.exec(s))) {
const pat = m[2].replace(/\\\//g, "/"), rep = m[3].replace(/\\\//g, "/"), flags = m[4] || "";
if (!pat) { msg("E35: No previous regular expression", true); return; }
const re = new RegExp(pat.replace(/[.*+?^$(){}|[\]\\]/g, "\\$&"), flags.includes("g") ? "g" : "");
let count = 0, lastLine = -1;
const from = m[1] ? 0 : st.r, to = m[1] ? st.lines.length - 1 : st.r;
for (let r = from; r <= to; r++) {
const before = st.lines[r];
const after = before.replace(re, () => { count++; return rep; });
if (after !== before) { st.lines[r] = after; lastLine = r; }
}
if (!count) { msg("E486: Pattern not found: " + pat, true); return; }
st.r = lastLine; st.c = firstNB(lastLine); st.want = st.c;
msg(count + " substitution" + (count > 1 ? "s" : "") + (m[1] ? " on " + (to - from + 1) + " lines" : ""));
return;
}
if (/^h(elp)?\b/.test(s)) { msg("Schro is the help. Try the Hint button.", false); return; }
msg("E492: Not an editor command: " + s, true);
}
// ---------- Input pipeline ----------
function dispatch(k) {
if (st.mode === "insert") insertKey(k);
else if (st.mode === "cmdline") cmdKey(k);
else normalKey(k);
}
function feed(k) {
if (!st.start) st.start = { snap: snap(), rec: [] };
st.start.rec.push(k);
dispatch(k);
if (st.mode === "insert") clampN();
settle();
}
function settle() {
if (st.mode === "normal" && !st.vis && !st.keys.length && st.start) {
const s = st.start; st.start = null;
if (!s.skip && !same(s.snap.lines, st.lines)) {
st.undo.push(s.snap.lines ? { lines: s.snap.lines, r: s.snap.r, c: s.snap.c } : s.snap);
if (st.undo.length > 300) st.undo.shift();
st.redo = [];
if (!s.noRepeat && !replaying) st.last = s.rec.slice();
}
clampN();
check();
}
render();
}
function check() {
if (st.done) return;
if (st.targets.length) {
const t = st.targets[st.tgt];
if (t && st.r === t.r && st.c === t.c) {
st.tgt++;
if (st.tgt >= st.targets.length) return win();
react(pick(YUM), "happy");
}
return;
}
if (LV.goal) {
const norm = (a) => a.map((s) => s.replace(/\s+$/, ""));
if (same(norm(st.lines), norm(LV.goal))) return win();
return;
}
if (LV.exit && st.exited) return win();
}
function starsFor(strokes, par) { return strokes <= par ? 3 : strokes <= Math.ceil(par * 1.6) ? 2 : 1; }
function win() {
st.done = true;
const s = starsFor(st.strokes, LV.par);
if (!best[levelIdx] || best[levelIdx] < s) { best[levelIdx] = s; store.set("best", best); }
react(s === 3 ? "purrfect!" : s === 2 ? "nice!" : "you did it!", "happy");
const w = $("win");
let stars = "";
for (let i = 1; i <= 3; i++) stars += i <= s ? "★" : "<span class=\"off\">★</span>";
const last = levelIdx === LEVELS.length - 1;
if (LV.exit) {
let total = 0; for (let i = 0; i < LEVELS.length; i++) total += best[i] || 0;
w.innerHTML = "<div class=\"sub mono\">[Process exited 0]</div><div class=\"big\">You escaped vim. Schro is so proud.</div>" +
"<div class=\"stars\">" + stars + "</div><div class=\"sub\">" + total + " / " + (LEVELS.length * 3) + " stars across the whole school</div>" +
"<div class=\"sub\"><kbd>r</kbd> play again from level 1 · <kbd>Enter</kbd> stay here</div>";
} else {
w.innerHTML = "<div class=\"big\">Level " + (levelIdx + 1) + " cleared</div><div class=\"stars\">" + stars + "</div>" +
"<div class=\"sub\">" + st.strokes + " keys · par " + LV.par + (s < 3 ? " · fewer keys for more stars" : "") + "</div>" +
"<div class=\"sub\"><kbd>Enter</kbd> " + (last ? "finish" : "next level") + " · <kbd>r</kbd> retry</div>";
}
w.classList.remove("hidden");
render();
}
// ---------- Rendering ----------
function hlRanges(line) {
const out = [];
if (!st.hl) return out;
let i = line.indexOf(st.hl);
while (i >= 0) { out.push([i, i + st.hl.length]); i = line.indexOf(st.hl, i + Math.max(1, st.hl.length)); }
return out;
}
function render() {
const L = st.lines, tgt = st.targets[st.tgt];
let vis = null;
if (st.vis) {
let a = { r: st.vis.ar, c: st.vis.ac }, b = { r: st.r, c: st.c };
if (cmp(b, a) < 0) { const t = a; a = b; b = t; }
vis = { a: a, b: b, line: st.vis.type === "V" };
}
const gw = (st.nu || st.rnu) ? String(L.length).length : 0;
const rows = Math.max(L.length, 5);
let html = "";
for (let r = 0; r < rows; r++) {
if (r >= L.length) { html += "<div class=\"ln\">" + (gw ? "<span class=\"gut\">" + " ".repeat(gw) + "</span>" : "") + "<span class=\"tilde\">~</span></div>"; continue; }
let g = "";
if (gw) {
const num = st.rnu ? (r === st.r ? (st.nu ? r + 1 : 0) : Math.abs(r - st.r)) : r + 1;
g = "<span class=\"gut" + (r === st.r ? " cur" : "") + "\">" + String(num).padStart(gw, " ") + "</span>";
}
const line = L[r], hls = hlRanges(line);
const extra = st.mode === "insert" && r === st.r && st.c >= line.length && line.length > 0 ? 1 : 0;
const n = Math.max(line.length, 1) + extra;
let cells = "";
for (let c = 0; c < n; c++) {
const ch = line[c], k = [];
if (r === st.r && c === st.c && !st.done) k.push(st.mode === "insert" ? "ci" : st.mode === "cmdline" ? "cn dim" : "cn");
if (vis && line.length && c < line.length) {
const inV = vis.line ? (r >= vis.a.r && r <= vis.b.r)
: (cmp({ r: r, c: c }, vis.a) >= 0 && cmp({ r: r, c: c }, vis.b) <= 0);
if (inV) k.push("vs");
}
for (const h of hls) if (c >= h[0] && c < h[1]) { k.push("hl"); break; }
if (tgt && !st.done && tgt.r === r && tgt.c === c) k.push("fish");
cells += "<span" + (k.length ? " class=\"" + k.join(" ") + "\"" : "") + ">" + (ch === undefined ? " " : esc(ch)) + "</span>";
}
html += "<div class=\"ln" + (r === st.r ? " cl" : "") + "\">" + g + "<span class=\"tx\">" + cells + "</span></div>";
}
$("buf").innerHTML = html;
// Status line
const mode = st.mode === "insert" ? "INSERT" : st.mode === "cmdline" ? "COMMAND" : st.vis ? (st.vis.type === "V" ? "V-LINE" : "VISUAL") : "NORMAL";
const mcls = st.mode === "insert" ? "m-insert" : st.mode === "cmdline" ? "m-command" : st.vis ? "m-visual" : "m-normal";
const dirty = !same(st.lines, st.saved);
let prog = "";
if (st.targets.length) prog = "🐟 " + Math.min(st.tgt, st.targets.length) + "/" + st.targets.length;
else if (LV.goal) prog = "goal";
else prog = "exit";
$("status").innerHTML =
"<span class=\"mode " + mcls + "\">" + mode + "</span>" +
"<span class=\"sl-file\">purrvim.txt" + (dirty ? " [+]" : "") + "</span>" +
"<span class=\"sl-grow\"></span>" +
"<span class=\"showcmd\">" + esc(st.keys.join("").replace(/<Esc>/g, "")) + "</span>" +
"<span class=\"sl-keys\">" + prog + " · keys " + st.strokes + "/" + LV.par + "</span>" +
"<span class=\"sl-pos\">" + (st.r + 1) + ":" + (st.c + 1) + "</span>";
// Command line
const cmd = $("cmd");
if (st.mode === "cmdline") { cmd.className = "cmd"; cmd.innerHTML = esc(st.cmdType + st.cmd) + "<span class=\"cc\"> </span>"; }
else { cmd.className = "cmd" + (st.err ? " err" : ""); cmd.textContent = st.msg || " "; }
// Lesson
$("lvl").innerHTML = "<span class=\"n\">" + (levelIdx + 1) + "/" + LEVELS.length + "</span> " + esc(LV.title) +
"<span class=\"chips\">" + LV.keys.split(" ").map((k) => "<kbd>" + esc(k) + "</kbd>").join("") + "</span>";
$("say").innerHTML = kbdify(LV.say);
$("hint").innerHTML = "Hint: " + kbdify(LV.hint);
// Goal
const goal = $("goal");
if (LV.goal) {
goal.classList.remove("hidden");
goal.innerHTML = "<h4>Make it look like this</h4>" + LV.goal.map((g, i) =>
"<div class=\"g" + ((st.lines[i] || "").replace(/\s+$/, "") === g && st.lines.length === LV.goal.length ? " ok" : "") + "\">" + esc(g || " ") + "</div>").join("");
} else goal.classList.add("hidden");
// Level dots
$("dots").innerHTML = LEVELS.map((l, i) =>
"<button class=\"dot" + (i === levelIdx ? " on" : "") + (best[i] ? " s" + best[i] : "") + "\" data-i=\"" + i + "\" title=\"" + (i + 1) + ". " + esc(l.title) + "\">" + (i + 1) + "</button>").join("");
$("bPrev").disabled = levelIdx === 0;
$("bNext").disabled = levelIdx === LEVELS.length - 1;
$("sleep").classList.toggle("hidden", focused);
drawFace();
remeasure();
}
let lastH = 0;
function remeasure() {
const h = document.documentElement.offsetHeight;
if (h === lastH) return;
lastH = h;
try { heightChecks = 0; updateHeight(); } catch (e) {}
}
// ---------- Keyboard ----------
const ARROWS = { ArrowLeft: "h", ArrowDown: "j", ArrowUp: "k", ArrowRight: "l" };
document.addEventListener("keydown", (e) => {
if (e.metaKey || e.altKey) return;
let k = e.key;
if (e.ctrlKey) {
if (k === "r") k = "<C-r>";
else if (k === "[" || k === "c") k = "<Esc>";
else return;
} else if (k === "Escape") k = "<Esc>";
else if (k === "Enter") k = "<CR>";
else if (k === "Backspace") k = "<BS>";
else if (k === "Tab") k = "<Tab>";
else if (ARROWS[k]) {
e.preventDefault();
if (st.mode === "normal" && !st.done) react("no arrows! try " + ARROWS[k], "wow");
return;
} else if (k.length !== 1) return;
e.preventDefault();
if (st.done) {
if (k === "<CR>" || k === "n") { if (!LV.exit && levelIdx < LEVELS.length - 1) load(levelIdx + 1); else if (!LV.exit) load(levelIdx); else $("win").classList.add("hidden"); }
else if (k === "r") load(LV.exit ? 0 : levelIdx);
return;
}
st.strokes++;
if (st.err || (st.mode === "normal" && !st.vis && !st.keys.length && st.msg && k !== ":" && k !== "/" && k !== "?")) {
if (!/^-- /.test(st.msg)) msg("");
}
feed(k);
});
// ---------- Focus and buttons ----------
function setFocus(f) { focused = f; mood = f ? "idle" : "sleep"; $("sleep").classList.toggle("hidden", f); drawFace(); }
window.addEventListener("focus", () => setFocus(true));
window.addEventListener("blur", () => setFocus(false));
$("root").addEventListener("pointerdown", () => { $("root").focus({ preventScroll: true }); setFocus(true); });
const refocus = () => $("root").focus({ preventScroll: true });
$("bHint").onclick = () => { hintShown = !hintShown; $("hint").classList.toggle("hidden", !hintShown); refocus(); remeasure(); };
$("bReset").onclick = () => { load(levelIdx); refocus(); };
$("bPrev").onclick = () => { if (levelIdx > 0) load(levelIdx - 1); refocus(); };
$("bNext").onclick = () => { if (levelIdx < LEVELS.length - 1) load(levelIdx + 1); refocus(); };
$("dots").addEventListener("click", (e) => { const b = e.target.closest(".dot"); if (b) { load(+b.dataset.i); refocus(); } });
if (!window.matchMedia || !matchMedia("(pointer: fine)").matches) $("note").textContent = "Purrvim needs a hardware keyboard.";
load(levelIdx);
setFocus(document.hasFocus());
})();