From 5be2130951118a5d193d3374b416256b709859d3 Mon Sep 17 00:00:00 2001 From: StrangeNoob Date: Fri, 3 Jul 2026 19:15:40 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Act=20IV=20opens=20=E2=80=94=20The?= =?UTF-8?q?=20Selection=20(visual=20mode)=20and=20Inner=20Sanctum=20(i\"/i?= =?UTF-8?q?(/ip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Archives begin: 24 of 29 curriculum lessons shipped. Engine: - Charwise visual mode: v anchors a selection, a motion subset shapes it, d/x/y/c operate on it (multi-row splice; y degrades to linewise across rows), esc/v leaves; undo restores the selection's start - Inner text objects beyond iw: i\" (quoted span, or the next one ahead, like Vim), i( (innermost pair, nesting-aware, line-local), ip (the paragraph block, blanks preserved; cip collapses to one line) - AllowedKeys gate now skips object/motion completions of an operator already in flight — di( no longer costs a heart for the \"(\" UI: - Selection is reverse-video highlighted in the buffer; -- VISUAL -- HUD - Act IV scaffolding: brass palette, THE ARCHIVES name/summary, welcome screen and title tagline now speak of four acts Content rules: - Act range widened to 1..4; a boss may sit only on its act's final lesson, and every act except the highest (under construction) must end with one. The finale test now asserts mid-game bosses do NOT trigger the finale; the full check returns with The Macro Forge Lessons: The Selection (act4-23, v/vy/vc) and Inner Sanctum (act4-24, ci\"/di(/dip), solutions verified at par. --- README.md | 3 +- assets/lessons/act4-23-the-selection.json | 38 +++++++ assets/lessons/act4-24-inner-sanctum.json | 38 +++++++ docs/LESSON-GAP-ANALYSIS.md | 15 +-- docs/LESSONS.md | 33 +++--- docs/lessons.csv | 4 +- internal/content/loader_test.go | 24 +++-- internal/content/solvable_test.go | 6 ++ internal/engine/edit.go | 126 +++++++++++++++++++++- internal/engine/engine.go | 14 ++- internal/engine/inner_objects_test.go | 56 ++++++++++ internal/engine/visual.go | 88 +++++++++++++++ internal/engine/visual_test.go | 55 ++++++++++ internal/ui/app_test.go | 20 +++- internal/ui/cmdline_hud_test.go | 11 ++ internal/ui/room.go | 21 ++++ internal/ui/styles.go | 7 +- internal/ui/title.go | 2 +- internal/ui/welcome.go | 6 +- 19 files changed, 524 insertions(+), 43 deletions(-) create mode 100644 assets/lessons/act4-23-the-selection.json create mode 100644 assets/lessons/act4-24-inner-sanctum.json create mode 100644 internal/engine/inner_objects_test.go create mode 100644 internal/engine/visual.go create mode 100644 internal/engine/visual_test.go diff --git a/README.md b/README.md index da943fc..2adf143 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # nvim-quest -**Learn Neovim through an epic three-act terminal quest — no Neovim required.** +**Learn Neovim through an epic four-act terminal quest — no Neovim required.** nvim-quest teaches real Vim motions and edits by making you *use* them. You press the actual keys (`h j k l`, `d w`, `c w`, `/` …) and a live buffer reacts instantly, @@ -115,6 +115,7 @@ of time and you retry from the first step. | **I** | The Cursor Dojo | The Two Stances · First Steps · Way of the Word · The Great Leaps | modes & `i`/`Esc`, `h j k l`, `w`/`b`, `0` `$` `gg` `G` | | **II** | The Motion Crypts | Hall of Insertion · The Line's Ends · The Deletion Pits · The Word's Edge · Echo Chamber · Opening Lines · The Inner Cut · Rewind · Mend & Weld · The Severing · The Shapeshifter | `i`/`a`, `A`/`I`/`^`, `x` `dw` `dd`, `e`, `yy`/`p`/`P`, `o`/`O`, `ciw`/`diw`, `u`/`Ctrl-r`, `r`/`~`/`J`, `D`/`C`/`ce`/`yw`, `cw` `cc` | | **III** | The Neon Grid | Trace Evasion · Backtrace · The Marksman · The Gatekeeper · The Command Sigils · The Great Substitution · Power Surge | `/` search & `n`, `*`/`N`, `f`/`;`/`,`, `%`, `:w`/`:wq`/`:q!`, `:%s/old/new/g`, count prefixes (`4w`, `3dd`, `4x`) | +| **IV** | The Archives | The Selection · Inner Sanctum *(more coming)* | visual mode `v` + `d`/`y`/`c`, text objects `ci"` `di(` `dip` | Each act has a distinct color palette (dojo greens, crypt embers, neon magenta/cyan) and ends with a timed boss. Lessons unlock sequentially; clearing an act's boss unlocks diff --git a/assets/lessons/act4-23-the-selection.json b/assets/lessons/act4-23-the-selection.json new file mode 100644 index 0000000..f7a85b9 --- /dev/null +++ b/assets/lessons/act4-23-the-selection.json @@ -0,0 +1,38 @@ +{ + "id": "act4-23-the-selection", + "act": 4, "order": 23, + "title": "The Selection", + "story": "The Archives open. Before automation comes sight: press v and watch your selection glow before you commit the cut.", + "challenges": [ + { + "id": "a4-selection-c1", + "intro": "Purge the middle of the incantation — see it selected first.", + "buffer": ["purge every last echo now"], "cursor": [0, 6], + "goal": { "type": "bufferEquals", "lines": ["purge now"] }, + "par": 6, "xp": 80, + "hint": "v starts a selection; motions grow it; d cuts everything selected.", + "newKeys": ["v"], + "allowedKeys": ["h", "j", "k", "l", "w", "b", "e", "0", "$", "^", "g", "G", "i", "a", "A", "I", "o", "O", "x", "d", "c", "y", "p", "P", "u", "ctrl+r", "r", "~", "J", "D", "C", "/", "n", "*", "N", "f", ";", ",", "%", ":", "v"] + }, + { + "id": "a4-selection-c2", + "intro": "Echo the spell-word — select it, yank it, paste it.", + "buffer": ["the spell of binding"], "cursor": [0, 4], + "goal": { "type": "bufferEquals", "lines": ["the spell spell of binding"] }, + "par": 7, "xp": 80, + "hint": "y yanks the selection; p pastes it after the cursor.", + "newKeys": ["vy"], + "allowedKeys": ["h", "j", "k", "l", "w", "b", "e", "0", "$", "^", "g", "G", "i", "a", "A", "I", "o", "O", "x", "d", "c", "y", "p", "P", "u", "ctrl+r", "r", "~", "J", "D", "C", "/", "n", "*", "N", "f", ";", ",", "%", ":", "v"] + }, + { + "id": "a4-selection-c3", + "intro": "The gate must swing — select 'of iron' and rewrite it.", + "buffer": ["the gate of iron holds"], "cursor": [0, 9], + "goal": { "type": "bufferEquals", "lines": ["the gate ajar holds"] }, + "par": 8, "xp": 80, + "hint": "c cuts the selection and drops you into Insert mode.", + "newKeys": ["vc"], + "allowedKeys": ["h", "j", "k", "l", "w", "b", "e", "0", "$", "^", "g", "G", "i", "a", "A", "I", "o", "O", "x", "d", "c", "y", "p", "P", "u", "ctrl+r", "r", "~", "J", "D", "C", "/", "n", "*", "N", "f", ";", ",", "%", ":", "v"] + } + ] +} diff --git a/assets/lessons/act4-24-inner-sanctum.json b/assets/lessons/act4-24-inner-sanctum.json new file mode 100644 index 0000000..eae5368 --- /dev/null +++ b/assets/lessons/act4-24-inner-sanctum.json @@ -0,0 +1,38 @@ +{ + "id": "act4-24-inner-sanctum", + "act": 4, "order": 24, + "title": "Inner Sanctum", + "story": "Deep in the Archives, meaning nests inside delimiters. Inner objects strike whole spans: quoted words, bracketed spells, entire paragraphs.", + "challenges": [ + { + "id": "a4-sanctum-c1", + "intro": "The password is wrong. Rewrite what's inside the quotes — from anywhere.", + "buffer": ["speak \"the old password\" aloud"], "cursor": [0, 10], + "goal": { "type": "bufferEquals", "lines": ["speak \"hooray\" aloud"] }, + "par": 9, "xp": 80, + "hint": "ci\" changes everything inside the quotes, wherever the cursor sits in them.", + "newKeys": ["ci\""], + "allowedKeys": ["h", "j", "k", "l", "w", "b", "e", "0", "$", "^", "g", "G", "i", "a", "A", "I", "o", "O", "x", "d", "c", "y", "p", "P", "u", "ctrl+r", "r", "~", "J", "D", "C", "/", "n", "*", "N", "f", ";", ",", "%", ":", "v"] + }, + { + "id": "a4-sanctum-c2", + "intro": "The trap is armed inside the brackets. Empty them — one strike.", + "buffer": ["disarm(the trap inside) safely"], "cursor": [0, 12], + "goal": { "type": "bufferEquals", "lines": ["disarm() safely"] }, + "par": 3, "xp": 80, + "hint": "di( deletes everything inside the parentheses.", + "newKeys": ["di("], + "allowedKeys": ["h", "j", "k", "l", "w", "b", "e", "0", "$", "^", "g", "G", "i", "a", "A", "I", "o", "O", "x", "d", "c", "y", "p", "P", "u", "ctrl+r", "r", "~", "J", "D", "C", "/", "n", "*", "N", "f", ";", ",", "%", ":", "v"] + }, + { + "id": "a4-sanctum-c3", + "intro": "Purge the corrupted stanza — every line of it, one command.", + "buffer": ["keep this verse", "", "corrupt line one", "corrupt line two", "", "keep this verse too"], "cursor": [3, 2], + "goal": { "type": "bufferEquals", "lines": ["keep this verse", "", "", "keep this verse too"] }, + "par": 3, "xp": 80, + "hint": "dip deletes the whole paragraph under the cursor.", + "newKeys": ["dip"], + "allowedKeys": ["h", "j", "k", "l", "w", "b", "e", "0", "$", "^", "g", "G", "i", "a", "A", "I", "o", "O", "x", "d", "c", "y", "p", "P", "u", "ctrl+r", "r", "~", "J", "D", "C", "/", "n", "*", "N", "f", ";", ",", "%", ":", "v"] + } + ] +} diff --git a/docs/LESSON-GAP-ANALYSIS.md b/docs/LESSON-GAP-ANALYSIS.md index 19b1ba8..b385f4b 100644 --- a/docs/LESSON-GAP-ANALYSIS.md +++ b/docs/LESSON-GAP-ANALYSIS.md @@ -1,6 +1,6 @@ # Lesson gap analysis: nvim-quest vs. the field -Compares our 22 shipped lessons (see [`lessons.csv`](lessons.csv)) against the major vim +Compares our 24 shipped lessons (see [`lessons.csv`](lessons.csv)) against the major vim curricula and games, so new lessons and mechanics can be picked by consensus rather than guesswork. Sources: @@ -42,8 +42,8 @@ guesswork. Sources: | Match pair | `%` | ✅ The Gatekeeper | vimtutor L4 | code-flavored buffers | | Substitute | `:s :%s` + `/g` | ✅ The Great Substitution | vimtutor L4, sequel | plain-text patterns; regex later | | **Ex basics** | `:w :wq :q!` | ✅ The Command Sigils | **vimtutor lesson 1** | the meme gap is CLOSED — this game teaches you how to exit vim | -| Text objects | `iw` only | ✅ partial | Learn-Vim, VimHero | `i" i( ip` are the daily drivers; generalize `pendingInner` | -| Visual mode | `v V Ctrl-v` | 🔜 Act IV | vimtutor L5, all advanced | | +| Text objects | `iw i" i( ip` | ✅ Inner Sanctum | Learn-Vim, VimHero | the daily drivers all shipped | +| Visual mode | `v V Ctrl-v` | ✅ partial (The Selection) | vimtutor L5, all advanced | charwise `v` shipped; `V`/`Ctrl-v` later | | Registers | `"a`–`"z` | 🔜 Act IV | Learn-Vim ch8, sequel | | | Macros | `q @` | 🔜 Act IV | Learn-Vim ch9, sequel | | | Marks | `m` `` ` `` | 🔜 Act IV | Learn-Vim | | @@ -59,11 +59,14 @@ Edge), `r/~/J` (Mend & Weld), `*/N` (Backtrace), `f/;/,` (The Marksman). **Shipped as the command pack:** `:w/:wq/:q!` (The Command Sigils), `:s/:%s//g` (The Great Substitution) — command-line mode landed, and with it the meme lesson. -Still open — every remaining item needs a medium-or-larger engine feature: +**Shipped as Act IV part 1:** charwise visual `v`+`d/y/c` (The Selection), `i" i( ip` +text objects (Inner Sanctum). + +Still open — the back half of Act IV: 1. **`.` repeat** — medium; pairs beautifully with combo/golf mechanics (fewer keys via `.`). -2. **More text objects `i" i( ip`** — generalize the existing `pendingInner` path. -3. Act IV as planned (visual, registers, macros, marks). +2. **Marks, registers** — medium each. +3. **`Ctrl-v` visual block, macros `q`/`@`** — the two large features, ending in The Archivist boss. ## Mechanics comparison (for "more playful games") diff --git a/docs/LESSONS.md b/docs/LESSONS.md index 356ccf2..4438d0a 100644 --- a/docs/LESSONS.md +++ b/docs/LESSONS.md @@ -10,7 +10,7 @@ content-integrity test picks it up. act--.json e.g. act2-06-the-deletion-pits.json ``` -`ACT` is 1–3 and `ORDER` is the global lesson order (lessons are sorted by `(act, order)`). +`ACT` is 1–4 and `ORDER` is the global lesson order (lessons are sorted by `(act, order)`). Keep `order` unique and increasing across the whole game. ## Schema @@ -81,7 +81,7 @@ gated. Omit `allowedKeys` (boss steps do) to allow everything. `internal/content/loader_test.go` and `solvable_test.go` will fail the build unless: -1. Every lesson has a unique `id`, an act in 1–3, and `order` increasing across the game. +1. Every lesson has a unique `id`, an act in 1–4, and `order` increasing across the game. 2. Every challenge & boss-step `id` is globally unique. 3. Every `cursor` is in bounds (`col` may equal the line length for append-at-end). 4. Every `goal.type` is valid and its payload field is non-empty. @@ -89,7 +89,8 @@ gated. Omit `allowedKeys` (boss steps do) to allow everything. empty hint shows the player nothing). 6. Regular challenges have `par >= 1` and `xp > 0`; bosses have `timeLimitSec >= 30`, `xp > 0`, and at least one step. -7. The act's final lesson (and only it) carries a `boss`. +7. A `boss` may sit only on its act's final lesson; every act except the highest + (still under construction) must end with one. 8. **Every challenge is solvable at par.** `solvable_test.go` plays an authored optimal key sequence through the real engine and asserts the goal is met within par. When you add a challenge, add its solution there too. @@ -110,7 +111,7 @@ To add a lesson teaching `t` (till char) — once the engine supports `t`: # Roadmap: the full curriculum -The target curriculum: 29 lessons across four acts — 22 shipped, 7 planned. Lesson +The target curriculum: 29 lessons across four acts — 24 shipped, 5 planned. Lesson selection follows the consensus analysis in [`LESSON-GAP-ANALYSIS.md`](LESSON-GAP-ANALYSIS.md) (vimtutor, Learn-Vim, Vim Adventures, VimGolf et al.); phases mirror the original design spec @@ -167,19 +168,21 @@ lesson must carry the act's boss — starting Act IV boss-less broke that invari They live in Act III instead (where `:%s` sat in the original roadmap), and Act IV stays the pure automation world.* -### Act IV · The Archives — power tools (all planned) +### Act IV · The Archives — power tools -Library/clockwork theme for automation. +Library/clockwork theme for automation. Under construction: until The Macro Forge +lands, this act has no boss and the game finale is unreachable (the content test +allows a boss-less final act while it grows). -| # | Lesson | Teaches | Engine work | -| --- | --- | --- | --- | -| 23 | The Selection | Visual `v` + `d/y/c` | medium: anchor + selection range | -| 24 | Inner Sanctum | `i" i( ip` text objects | small-medium: generalize `pendingInner` beyond `w` | -| 25 | The Echo Rite | `.` repeat | medium: record + replay the last change | -| 26 | Waypoints | marks `m{a-z}` `` `{a-z} `` | medium: per-buffer mark table | -| 27 | The Registers | `"a`–`"z` yank/paste | medium: register map through yank/delete/paste | -| 28 | Block Party | `Ctrl-v` + column `I/A` | larger: rectangular selection | -| 29 | The Macro Forge · **boss: The Archivist** | `q{reg}…q` `@{reg}` `@@` | larger: keystroke record & replay | +| # | Lesson | Teaches | Status | Engine work | +| --- | --- | --- | --- | --- | +| 23 | The Selection | Visual `v` + `d/y/c` | ✅ | | +| 24 | Inner Sanctum | `i" i( ip` text objects | ✅ | | +| 25 | The Echo Rite | `.` repeat | 📋 | medium: record + replay the last change | +| 26 | Waypoints | marks `m{a-z}` `` `{a-z} `` | 📋 | medium: per-buffer mark table | +| 27 | The Registers | `"a`–`"z` yank/paste | 📋 | medium: register map through yank/delete/paste | +| 28 | Block Party | `Ctrl-v` + column `I/A` | 📋 | larger: rectangular selection | +| 29 | The Macro Forge · **boss: The Archivist** | `q{reg}…q` `@{reg}` `@@` | 📋 | larger: keystroke record & replay | Once command-line mode exists, `:g/pattern/d` (Learn-Vim ch13) is a natural bonus room inside The Great Substitution — no extra lesson needed. diff --git a/docs/lessons.csv b/docs/lessons.csv index 1d36798..874bafb 100644 --- a/docs/lessons.csv +++ b/docs/lessons.csv @@ -21,8 +21,8 @@ shipped,3,19,The Gatekeeper,%,2,, shipped,3,20,The Command Sigils,:w :wq :q!,3,, shipped,3,21,The Great Substitution,:s :%s + /g flag,3,, shipped,3,22,Power Surge,counts (4w 3dd),3,The Grid Core, -planned,4,23,The Selection,visual mode v + d/y/c,,,medium: visual mode with anchor + selection range -planned,4,24,Inner Sanctum,"i"" i( ip text objects",,,small-medium: generalize pendingInner beyond w +shipped,4,23,The Selection,visual mode v + d/y/c,3,, +shipped,4,24,Inner Sanctum,"i"" i( ip text objects",3,, planned,4,25,The Echo Rite,. repeat,,,medium: record + replay last change planned,4,26,Waypoints,marks m{a-z} + jumps `a 'a,,,medium: per-buffer mark table planned,4,27,The Registers,"named registers ""a-""z",,,medium: register map through yank/delete/paste diff --git a/internal/content/loader_test.go b/internal/content/loader_test.go index ea058ca..c989c4e 100644 --- a/internal/content/loader_test.go +++ b/internal/content/loader_test.go @@ -22,7 +22,7 @@ func TestAllLoadsAndValidates(t *testing.T) { t.Errorf("duplicate lesson id %s", l.ID) } seenLessons[l.ID] = true - if l.Act < 1 || l.Act > 3 { + if l.Act < 1 || l.Act > 4 { t.Errorf("%s: act %d out of range", l.ID, l.Act) } if i > 0 { @@ -56,21 +56,27 @@ func TestAllLoadsAndValidates(t *testing.T) { } } } - if len(lessons) != 22 { - t.Errorf("expected 22 lessons, got %d", len(lessons)) + if len(lessons) != 24 { + t.Errorf("expected 24 lessons, got %d", len(lessons)) } - bosses := 0 lastInAct := map[int]string{} + maxAct := 0 for _, l := range lessons { lastInAct[l.Act] = l.ID - if l.Boss != nil { - bosses++ - } + maxAct = max(maxAct, l.Act) } - if bosses != 3 { - t.Errorf("expected 3 bosses, got %d", bosses) + // A boss may sit only on its act's final lesson. + for _, l := range lessons { + if l.Boss != nil && lastInAct[l.Act] != l.ID { + t.Errorf("%s carries a boss but is not act %d's final lesson", l.ID, l.Act) + } } + // Every finished act ends in a boss. The highest act may still be under + // construction — its boss ships with its final planned lesson. for act, id := range lastInAct { + if act == maxAct { + continue + } for _, l := range lessons { if l.ID == id && l.Boss == nil { t.Errorf("act %d final lesson %s must carry the boss", act, id) diff --git a/internal/content/solvable_test.go b/internal/content/solvable_test.go index fdf24a6..c064289 100644 --- a/internal/content/solvable_test.go +++ b/internal/content/solvable_test.go @@ -67,6 +67,12 @@ func TestEveryChallengeIsSolvable(t *testing.T) { "a3l10c1": {"4", "w"}, "a3l10c2": {"3", "d", "d"}, "a3l10c3": {"4", "x"}, + "a4-selection-c1": {"v", "e", "e", "e", "l", "d"}, + "a4-selection-c2": {"v", "e", "l", "y", "e", "l", "p"}, + "a4-selection-c3": {"v", "e", "e", "c", "a", "j", "a", "r"}, + "a4-sanctum-c1": {"c", "i", "\"", "h", "o", "o", "r", "a", "y"}, + "a4-sanctum-c2": {"d", "i", "("}, + "a4-sanctum-c3": {"d", "i", "p"}, } lessons, err := All() diff --git a/internal/engine/edit.go b/internal/engine/edit.go index 9f464b1..19cc7aa 100644 --- a/internal/engine/edit.go +++ b/internal/engine/edit.go @@ -100,7 +100,8 @@ func (s *Simulator) applyOperator(key string) Event { if s.pendingInner { op := s.pendingOp s.clearPending() - if key == "w" { + switch key { + case "w": s.snapshot() s.deleteInnerWord() if op == "c" { @@ -111,6 +112,12 @@ func (s *Simulator) applyOperator(key string) Event { } s.clampCol() // diw: settle the cursor on a valid normal-mode column return Event{EvEdited} + case `"`: + return s.innerQuote(op) + case "(", ")", "b": + return s.innerParen(op) + case "p": + return s.innerParagraph(op) } return Event{EvInvalid} } @@ -346,6 +353,123 @@ func (s *Simulator) openLine(below bool) { s.Mode = ModeInsert } +// cutInnerSpan removes line[a+1:b] (the inside of a delimiter pair at a and +// b), leaving the cursor just after the opener; c-operators enter insert. +func (s *Simulator) cutInnerSpan(op string, a, b int) Event { + line := s.line() + if b > a+1 { + s.snapshot() + s.setLine(line[:a+1] + line[b:]) + } else if op == "c" { + s.snapshot() // nothing to cut, but ci"/ci( still insert undoably + } else { + return Event{EvNone} + } + s.Cursor.Col = a + 1 + if op == "c" { + s.Mode = ModeInsert + return Event{EvModeChanged} + } + return Event{EvEdited} +} + +// innerQuote implements di"/ci" on the current line: the pair of quotes +// containing the cursor, or the next pair after it (Vim's behavior). +func (s *Simulator) innerQuote(op string) Event { + line := s.line() + var quotes []int + for i := 0; i < len(line); i++ { + if line[i] == '"' { + quotes = append(quotes, i) + } + } + col := s.Cursor.Col + // First pair whose closing quote is at or past the cursor: the pair the + // cursor is inside, or the next one ahead of it (Vim's behavior). + for i := 0; i+1 < len(quotes); i += 2 { + if a, b := quotes[i], quotes[i+1]; col <= b { + return s.cutInnerSpan(op, a, b) + } + } + return Event{EvNone} +} + +// innerParen implements di(/ci( on the current line, nesting-aware: the +// innermost pair enclosing (or under) the cursor. +// ponytail: line-local like innerQuote — cross-line pairs when a lesson needs them. +func (s *Simulator) innerParen(op string) Event { + line := s.line() + col := min(s.Cursor.Col, max(0, len(line)-1)) + a, b := -1, -1 + depth := 0 + for i := col; i >= 0; i-- { + switch { + case line[i] == ')' && i != col: + depth++ + case line[i] == '(': + if depth == 0 { + a = i + } else { + depth-- + } + } + if a >= 0 { + break + } + } + depth = 0 + for j := col; j < len(line); j++ { + switch { + case line[j] == '(' && j != col: + depth++ + case line[j] == ')': + if depth == 0 { + b = j + } else { + depth-- + } + } + if b >= 0 { + break + } + } + if a < 0 || b < 0 || b <= a { + return Event{EvNone} + } + return s.cutInnerSpan(op, a, b) +} + +// innerParagraph implements dip/cip: the contiguous block of non-empty lines +// around the cursor. Surrounding blank lines stay, like Vim. +func (s *Simulator) innerParagraph(op string) Event { + if s.line() == "" { + return Event{EvNone} + } + start, end := s.Cursor.Row, s.Cursor.Row + for start > 0 && s.Buffer[start-1] != "" { + start-- + } + for end < len(s.Buffer)-1 && s.Buffer[end+1] != "" { + end++ + } + s.snapshot() + if op == "c" { + // cip: the block collapses to one empty line and we type into it. + out := append([]string{}, s.Buffer[:start]...) + out = append(out, "") + s.Buffer = append(out, s.Buffer[end+1:]...) + s.Cursor = Pos{start, 0} + s.Mode = ModeInsert + return Event{EvModeChanged} + } + s.Buffer = append(s.Buffer[:start], s.Buffer[end+1:]...) + if len(s.Buffer) == 0 { + s.Buffer = []string{""} + } + s.Cursor = Pos{min(start, len(s.Buffer)-1), 0} + return Event{EvEdited} +} + // deleteInnerWord removes the whole space-delimited word the cursor sits on // (no trailing space), leaving the cursor at the word's start (diw / ciw). func (s *Simulator) deleteInnerWord() { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index e3b36c4..f39c2e2 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -11,6 +11,7 @@ const ( ModeInsert ModeSearch ModeCmdline + ModeVisual ) type EventKind int @@ -64,6 +65,8 @@ type Simulator struct { CmdQuery string // the command being typed after : lastCommand string // the last executed : command, for commandRun goals + + VisualAnchor Pos // where v was pressed; the selection's fixed end } func New(lines []string, cursor Pos) *Simulator { @@ -110,6 +113,8 @@ func (s *Simulator) Press(key string) Event { return s.pressSearch(key) case ModeCmdline: return s.pressCmdline(key) + case ModeVisual: + return s.pressVisual(key) default: return s.pressNormal(key) } @@ -138,7 +143,10 @@ func (s *Simulator) pressNormal(key string) Event { } return Event{EvNone} } - if s.AllowedKeys != nil && !s.AllowedKeys[key] { + // The gate applies to commands, not to the completion of one already in + // flight: with d/c/y armed, the object or delimiter that follows (w, ", (, + // p) is a continuation — the operator itself was gated when pressed. + if s.AllowedKeys != nil && !s.AllowedKeys[key] && s.pendingOp == "" { s.clearPending() return Event{EvInvalid} } @@ -267,6 +275,10 @@ func (s *Simulator) pressNormal(key string) Event { s.Mode = ModeCmdline s.CmdQuery = "" return Event{EvModeChanged} + case "v": + s.Mode = ModeVisual + s.VisualAnchor = s.Cursor + return Event{EvModeChanged} case "n": if s.jumpToMatch(s.lastSearch, true) { return Event{EvSearchJumped} diff --git a/internal/engine/inner_objects_test.go b/internal/engine/inner_objects_test.go new file mode 100644 index 0000000..c517a22 --- /dev/null +++ b/internal/engine/inner_objects_test.go @@ -0,0 +1,56 @@ +package engine + +import ( + "slices" + "testing" +) + +func TestInnerQuoteParenParagraph(t *testing.T) { + tests := []struct { + name string + buffer []string + cursor Pos + keys []string + wantBuffer []string + wantCursor Pos + wantMode Mode + }{ + {`ci" rewrites the quoted text`, []string{`speak "the old password" aloud`}, Pos{0, 10}, + []string{"c", "i", `"`, "h", "i"}, []string{`speak "hi" aloud`}, Pos{0, 9}, ModeInsert}, + {`di" works from the opening quote itself`, []string{`say "boo" now`}, Pos{0, 4}, + []string{"d", "i", `"`}, []string{`say "" now`}, Pos{0, 5}, ModeNormal}, + {`di" with no quotes is a harmless no-op`, []string{"nothing quoted"}, Pos{0, 3}, + []string{"d", "i", `"`}, []string{"nothing quoted"}, Pos{0, 3}, ModeNormal}, + {"di( empties the parens", []string{"disarm(the trap inside) safely"}, Pos{0, 12}, + []string{"d", "i", "("}, []string{"disarm() safely"}, Pos{0, 7}, ModeNormal}, + {"di( targets the innermost pair", []string{"f(g(x), y)"}, Pos{0, 4}, + []string{"d", "i", "("}, []string{"f(g(), y)"}, Pos{0, 4}, ModeNormal}, + {"ci( inserts inside the pair", []string{"cast(fire)"}, Pos{0, 6}, + []string{"c", "i", "(", "i", "c", "e"}, []string{"cast(ice)"}, Pos{0, 8}, ModeInsert}, + {"di( outside any parens is a harmless no-op", []string{"no parens here"}, Pos{0, 0}, + []string{"d", "i", "("}, []string{"no parens here"}, Pos{0, 0}, ModeNormal}, + {"dip deletes the paragraph, keeping the blanks", []string{"keep", "", "purge a", "purge b", "", "tail"}, Pos{2, 0}, + []string{"d", "i", "p"}, []string{"keep", "", "", "tail"}, Pos{2, 0}, ModeNormal}, + {"cip clears the paragraph into insert", []string{"a", "", "x y", "z", ""}, Pos{2, 0}, + []string{"c", "i", "p", "n"}, []string{"a", "", "n", ""}, Pos{2, 1}, ModeInsert}, + {"dip is undoable", []string{"one", "two"}, Pos{0, 0}, + []string{"d", "i", "p", "u"}, []string{"one", "two"}, Pos{0, 0}, ModeNormal}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := New(tt.buffer, tt.cursor) + for _, k := range tt.keys { + s.Press(k) + } + if !slices.Equal(s.Buffer, tt.wantBuffer) { + t.Errorf("buffer = %q, want %q", s.Buffer, tt.wantBuffer) + } + if s.Cursor != tt.wantCursor { + t.Errorf("cursor = %v, want %v", s.Cursor, tt.wantCursor) + } + if s.Mode != tt.wantMode { + t.Errorf("mode = %v, want %v", s.Mode, tt.wantMode) + } + }) + } +} diff --git a/internal/engine/visual.go b/internal/engine/visual.go new file mode 100644 index 0000000..5ebe7f6 --- /dev/null +++ b/internal/engine/visual.go @@ -0,0 +1,88 @@ +package engine + +// pressVisual handles keys while a charwise visual selection is active. It +// understands a motion subset (enough to shape a selection), the operators +// d/x/y/c on the selection, and esc/v to leave. +// ponytail: no counts, f, or text objects in visual — add when a lesson needs them. +func (s *Simulator) pressVisual(key string) Event { + switch key { + case "esc", "v": + s.Mode = ModeNormal + return Event{EvModeChanged} + case "h", "j", "k", "l", "w", "b", "e": + s.move(key) + return Event{EvMoved} + case "0": + s.Cursor.Col = 0 + return Event{EvMoved} + case "$": + s.Cursor.Col = max(0, len(s.line())-1) + return Event{EvMoved} + case "^": + s.Cursor.Col = firstNonBlank(s.line()) + s.clampCol() + return Event{EvMoved} + case "d", "x": + s.deleteSelection() + s.Mode = ModeNormal + s.clampCol() + return Event{EvEdited} + case "c": + // Like d, but the cut point is a valid insert position — no clamp. + s.deleteSelection() + s.Mode = ModeInsert + return Event{EvModeChanged} + case "y": + s.yankSelection() + s.Mode = ModeNormal + return Event{EvNone} + } + return Event{EvInvalid} +} + +// Selection returns the visual range ordered start ≤ end (inclusive). +func (s *Simulator) Selection() (Pos, Pos) { + a, c := s.VisualAnchor, s.Cursor + if a.Row > c.Row || (a.Row == c.Row && a.Col > c.Col) { + return c, a + } + return a, c +} + +// deleteSelection removes the inclusive visual range and leaves the cursor +// at its start. Multi-row selections splice the first row's prefix onto the +// last row's suffix. +func (s *Simulator) deleteSelection() { + start, end := s.Selection() + s.Cursor = start // before the snapshot, so undo restores the change's start + s.snapshot() + first := s.Buffer[start.Row] + last := s.Buffer[end.Row] + tail := "" + if end.Col+1 < len(last) { + tail = last[end.Col+1:] + } + joined := first[:min(start.Col, len(first))] + tail + out := append([]string{}, s.Buffer[:start.Row]...) + out = append(out, joined) + out = append(out, s.Buffer[end.Row+1:]...) + s.Buffer = out + s.Cursor = start +} + +// yankSelection fills the register from the selection: charwise within one +// row; whole rows otherwise. +// ponytail: multi-row charwise yank degrades to linewise — split-line paste +// isn't modeled; upgrade if a lesson ever needs it. +func (s *Simulator) yankSelection() { + start, end := s.Selection() + if start.Row == end.Row { + line := s.Buffer[start.Row] + s.yank = []string{line[min(start.Col, len(line)):min(end.Col+1, len(line))]} + s.yankLinewise = false + } else { + s.yank = append([]string(nil), s.Buffer[start.Row:end.Row+1]...) + s.yankLinewise = true + } + s.Cursor = start +} diff --git a/internal/engine/visual_test.go b/internal/engine/visual_test.go new file mode 100644 index 0000000..7fddda0 --- /dev/null +++ b/internal/engine/visual_test.go @@ -0,0 +1,55 @@ +package engine + +import ( + "slices" + "testing" +) + +func TestVisualMode(t *testing.T) { + tests := []struct { + name string + buffer []string + cursor Pos + keys []string + wantBuffer []string + wantCursor Pos + wantMode Mode + }{ + {"v motions d cuts the selection", []string{"purge every last echo now"}, Pos{0, 6}, + []string{"v", "e", "e", "e", "l", "d"}, []string{"purge now"}, Pos{0, 6}, ModeNormal}, + {"v e c rewrites the selection", []string{"the gate of iron holds"}, Pos{0, 9}, + []string{"v", "e", "e", "c", "a", "j", "a", "r"}, []string{"the gate ajar holds"}, Pos{0, 13}, ModeInsert}, + {"v y yanks charwise for an inline paste", []string{"the spell of binding"}, Pos{0, 4}, + []string{"v", "e", "l", "y", "e", "l", "p"}, []string{"the spell spell of binding"}, Pos{0, 15}, ModeNormal}, + {"esc leaves visual without changes", []string{"abc"}, Pos{0, 0}, + []string{"v", "l", "esc"}, []string{"abc"}, Pos{0, 1}, ModeNormal}, + {"v toggles visual off", []string{"abc"}, Pos{0, 0}, + []string{"v", "v"}, []string{"abc"}, Pos{0, 0}, ModeNormal}, + {"a backward selection cuts the same range", []string{"abcdef"}, Pos{0, 3}, + []string{"v", "h", "h", "d"}, []string{"aef"}, Pos{0, 1}, ModeNormal}, + // vim leaves both surrounding spaces: row 0's trailing one and row 1's leading one. + {"a multi-line selection cuts across rows", []string{"one two", "three four"}, Pos{0, 4}, + []string{"v", "j", "d"}, []string{"one four"}, Pos{0, 4}, ModeNormal}, + {"x also cuts the selection", []string{"abcd"}, Pos{0, 1}, + []string{"v", "l", "x"}, []string{"ad"}, Pos{0, 1}, ModeNormal}, + {"visual delete is undoable", []string{"abcdef"}, Pos{0, 1}, + []string{"v", "l", "d", "u"}, []string{"abcdef"}, Pos{0, 1}, ModeNormal}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := New(tt.buffer, tt.cursor) + for _, k := range tt.keys { + s.Press(k) + } + if !slices.Equal(s.Buffer, tt.wantBuffer) { + t.Errorf("buffer = %q, want %q", s.Buffer, tt.wantBuffer) + } + if s.Cursor != tt.wantCursor { + t.Errorf("cursor = %v, want %v", s.Cursor, tt.wantCursor) + } + if s.Mode != tt.wantMode { + t.Errorf("mode = %v, want %v", s.Mode, tt.wantMode) + } + }) + } +} diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 79ffa2d..9664e12 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -236,15 +236,27 @@ func TestNextActAnnouncementOnBossClear(t *testing.T) { } } -func TestFinaleOnFinalBoss(t *testing.T) { +func TestFinaleAwaitsTheFinalActBoss(t *testing.T) { m := newTestModel(t) - m.lessonIdx = len(m.lessons) - 1 // the final boss (Act III · The Grid Core) + // Act IV is under construction: its last lesson carries no boss yet, so + // the game finale is unreachable. Clearing the last boss that DOES exist + // is an act-complete, not the finale. The clear-the-final-boss assertion + // returns when The Macro Forge (lesson 29, boss: The Archivist) ships. + last := -1 + for i, l := range m.lessons { + if l.Boss != nil { + last = i + } + } + m.lessonIdx = last m.inBoss = true mm, _ := m.awardBoss() m = mm.(Model) - if !m.resGameComplete { - t.Fatal("clearing the final boss should set resGameComplete") + if m.resGameComplete { + t.Fatal("a mid-game boss must not trigger the finale") } + // The finale view itself stays covered until then. + m.resGameComplete = true m.scr = screenResults if v := m.View(); !strings.Contains(v, "MASTERED") { t.Errorf("finale should celebrate mastery; got:\n%s", v) diff --git a/internal/ui/cmdline_hud_test.go b/internal/ui/cmdline_hud_test.go index cfe606a..7fa3bd6 100644 --- a/internal/ui/cmdline_hud_test.go +++ b/internal/ui/cmdline_hud_test.go @@ -16,3 +16,14 @@ func TestCmdlineShowsInHUD(t *testing.T) { t.Errorf("view does not echo the pending command %q", ":w") } } + +// Visual mode must announce itself in the HUD like the other modes do. +func TestVisualModeShowsInHUD(t *testing.T) { + m := newTestModel(t) + m.lessonIdx = bossLessonIdx(m, 1) + m, _ = m.startBoss() + m = press(t, m, "v") + if view := m.View(); !strings.Contains(view, "-- VISUAL --") { + t.Error("view does not show -- VISUAL --") + } +} diff --git a/internal/ui/room.go b/internal/ui/room.go index 3069d33..b7595b4 100644 --- a/internal/ui/room.go +++ b/internal/ui/room.go @@ -302,6 +302,8 @@ func (m Model) renderBuffer() string { switch { case m.flash: lines = append(lines, " "+successStyle.Render(line)) + case m.sim.Mode == engine.ModeVisual: + lines = append(lines, " "+renderSelected(line, i, m.sim)) case i == m.sim.Cursor.Row: col := m.sim.Cursor.Col r := []rune(line) @@ -319,6 +321,23 @@ func (m Model) renderBuffer() string { return strings.Join(lines, "\n") } +// renderSelected reverse-videos the part of row's line that falls inside the +// visual selection (inclusive, possibly spanning rows). +func renderSelected(line string, row int, sim *engine.Simulator) string { + start, end := sim.Selection() + if row < start.Row || row > end.Row { + return line + } + from, to := 0, len(line) + if row == start.Row { + from = min(start.Col, len(line)) + } + if row == end.Row { + to = min(end.Col+1, len(line)) + } + return line[:from] + cursorStyle.Render(line[from:to]) + line[to:] +} + func (m Model) renderHUD(ch content.Challenge) string { mode := "-- NORMAL --" switch m.sim.Mode { @@ -328,6 +347,8 @@ func (m Model) renderHUD(ch content.Challenge) string { mode = "/" + m.sim.SearchQuery case engine.ModeCmdline: mode = ":" + m.sim.CmdQuery + case engine.ModeVisual: + mode = "-- VISUAL --" } parts := []string{mode} if p := m.sim.Pending(); p != "" { diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 4ff94c8..8e773e9 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -7,11 +7,12 @@ type Palette struct { Accent lipgloss.Color } -// Per-act palettes: dojo greens, crypt embers, neon grid. +// Per-act palettes: dojo greens, crypt embers, neon grid, archive brass. var palettes = map[int]Palette{ 1: {Primary: lipgloss.Color("114"), Accent: lipgloss.Color("230")}, 2: {Primary: lipgloss.Color("214"), Accent: lipgloss.Color("203")}, 3: {Primary: lipgloss.Color("213"), Accent: lipgloss.Color("51")}, + 4: {Primary: lipgloss.Color("178"), Accent: lipgloss.Color("187")}, } func paletteFor(act int) Palette { @@ -34,6 +35,8 @@ func actName(act int) string { return "THE MOTION CRYPTS" case 3: return "THE NEON GRID" + case 4: + return "THE ARCHIVES" } return "" } @@ -47,6 +50,8 @@ func actSummary(act int) string { return "i/a · x · dw · dd · yy/p · cw · cc" case 3: return "/ search · n · counts (4w, 3dd)" + case 4: + return "v visual · ci\"/di( · dip" } return "" } diff --git a/internal/ui/title.go b/internal/ui/title.go index 5b32a24..15aa85f 100644 --- a/internal/ui/title.go +++ b/internal/ui/title.go @@ -77,7 +77,7 @@ func (m Model) viewTitle() string { }, "\n") var b strings.Builder b.WriteString(lipgloss.NewStyle().Foreground(pal.Primary).Render(logo) + "\n") - b.WriteString(dimStyle.Render("learn the blade cursor · three acts · one journey") + "\n\n") + b.WriteString(dimStyle.Render("learn the blade cursor · four acts · one journey") + "\n\n") for i, item := range menuItems { line := " " + item style := lipgloss.NewStyle() diff --git a/internal/ui/welcome.go b/internal/ui/welcome.go index 010fa51..808ae28 100644 --- a/internal/ui/welcome.go +++ b/internal/ui/welcome.go @@ -26,13 +26,15 @@ func (m Model) viewWelcome() string { b.WriteString(title.Render("✨ WELCOME, TRAVELLER ✨") + "\n\n") b.WriteString("You'll learn real Neovim by playing it — you press the\n") b.WriteString("actual keys (" + bold.Render("h j k l") + ", not the arrow keys).\n\n") - b.WriteString("Your journey runs through three worlds:\n") + b.WriteString("Your journey runs through four worlds:\n") b.WriteString(lipgloss.NewStyle().Foreground(paletteFor(1).Primary).Render(" Act I · The Cursor Dojo") + dimStyle.Render(" — move and edit") + "\n") b.WriteString(lipgloss.NewStyle().Foreground(paletteFor(2).Primary).Render(" Act II · The Motion Crypts") + dimStyle.Render(" — delete, change, yank") + "\n") b.WriteString(lipgloss.NewStyle().Foreground(paletteFor(3).Primary).Render(" Act III · The Neon Grid") + - dimStyle.Render(" — search and counts") + "\n\n") + dimStyle.Render(" — search and commands") + "\n") + b.WriteString(lipgloss.NewStyle().Foreground(paletteFor(4).Primary).Render(" Act IV · The Archives") + + dimStyle.Render(" — select and automate") + "\n\n") b.WriteString(bold.Render("On the map") + "\n") b.WriteString(" ▶ where you are now\n") b.WriteString(" 🔒 locked — clear the lesson before it to open\n") From ccd4fce555720f1cd8d1b04c4d04f9e0a9ee604e Mon Sep 17 00:00:00 2001 From: StrangeNoob Date: Fri, 3 Jul 2026 19:26:09 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20harden=20Act=20IV=20pack=20per=20rev?= =?UTF-8?q?iew=20=E2=80=94=20empty-line=20di(=20panic,=20gate=20width,=20v?= =?UTF-8?q?isual=20gating,=20rune-safe=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - innerParen guards the empty line (di( panicked; regression test added) - The allowedKeys bypass is now exactly as wide as an armed inner-object prefix: delimiters complete di"/ci( freely, but an armed operator no longer smuggles count digits past the gate (test added) - Visual-mode commands respect allowedKeys like normal-mode ones (esc free) - renderSelected slices by rune, matching the cursor-render path - Finale test fails loudly if no boss lesson exists - README: palette list gains archive brass; boss claim scoped to Act IV's under-construction state --- README.md | 5 +++-- internal/engine/edit.go | 5 ++++- internal/engine/engine.go | 9 ++++---- internal/engine/inner_objects_test.go | 31 +++++++++++++++++++++++++++ internal/engine/visual.go | 5 +++++ internal/ui/app_test.go | 3 +++ internal/ui/room.go | 10 +++++---- 7 files changed, 57 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2adf143..e8151ee 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,9 @@ of time and you retry from the first step. | **III** | The Neon Grid | Trace Evasion · Backtrace · The Marksman · The Gatekeeper · The Command Sigils · The Great Substitution · Power Surge | `/` search & `n`, `*`/`N`, `f`/`;`/`,`, `%`, `:w`/`:wq`/`:q!`, `:%s/old/new/g`, count prefixes (`4w`, `3dd`, `4x`) | | **IV** | The Archives | The Selection · Inner Sanctum *(more coming)* | visual mode `v` + `d`/`y`/`c`, text objects `ci"` `di(` `dip` | -Each act has a distinct color palette (dojo greens, crypt embers, neon magenta/cyan) -and ends with a timed boss. Lessons unlock sequentially; clearing an act's boss unlocks +Each act has a distinct color palette (dojo greens, crypt embers, neon magenta/cyan, +archive brass) and ends with a timed boss — Act IV's boss arrives with its final +lesson, The Macro Forge. Lessons unlock sequentially; clearing an act's boss unlocks the next act. The curriculum is inspired by diff --git a/internal/engine/edit.go b/internal/engine/edit.go index 19cc7aa..0588e7d 100644 --- a/internal/engine/edit.go +++ b/internal/engine/edit.go @@ -399,7 +399,10 @@ func (s *Simulator) innerQuote(op string) Event { // ponytail: line-local like innerQuote — cross-line pairs when a lesson needs them. func (s *Simulator) innerParen(op string) Event { line := s.line() - col := min(s.Cursor.Col, max(0, len(line)-1)) + if line == "" { + return Event{EvNone} + } + col := min(s.Cursor.Col, len(line)-1) a, b := -1, -1 depth := 0 for i := col; i >= 0; i-- { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index f39c2e2..c5878c9 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -143,10 +143,11 @@ func (s *Simulator) pressNormal(key string) Event { } return Event{EvNone} } - // The gate applies to commands, not to the completion of one already in - // flight: with d/c/y armed, the object or delimiter that follows (w, ", (, - // p) is a continuation — the operator itself was gated when pressed. - if s.AllowedKeys != nil && !s.AllowedKeys[key] && s.pendingOp == "" { + // The gate applies to commands, not to the delimiter completing an armed + // inner object: the " of di" or ( of ci( is a continuation — d and i were + // both gated when pressed. Everything else (motions, count digits) stays + // gated even mid-operator. + if s.AllowedKeys != nil && !s.AllowedKeys[key] && !s.pendingInner { s.clearPending() return Event{EvInvalid} } diff --git a/internal/engine/inner_objects_test.go b/internal/engine/inner_objects_test.go index c517a22..a338a99 100644 --- a/internal/engine/inner_objects_test.go +++ b/internal/engine/inner_objects_test.go @@ -36,6 +36,18 @@ func TestInnerQuoteParenParagraph(t *testing.T) { {"dip is undoable", []string{"one", "two"}, Pos{0, 0}, []string{"d", "i", "p", "u"}, []string{"one", "two"}, Pos{0, 0}, ModeNormal}, } + // The critical edge: di( on an empty line must not panic. + tests = append(tests, struct { + name string + buffer []string + cursor Pos + keys []string + wantBuffer []string + wantCursor Pos + wantMode Mode + }{"di( on an empty line is a harmless no-op", []string{""}, Pos{0, 0}, + []string{"d", "i", "("}, []string{""}, Pos{0, 0}, ModeNormal}) + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := New(tt.buffer, tt.cursor) @@ -54,3 +66,22 @@ func TestInnerQuoteParenParagraph(t *testing.T) { }) } } + +// The AllowedKeys gate bypass is exactly as wide as an armed inner-object +// prefix: delimiters complete di"/di( freely, but an armed operator does not +// smuggle in count digits or other gated keys. +func TestAllowedKeysGateWithPendingOperator(t *testing.T) { + s := New([]string{`say "boo" now`}, Pos{0, 5}) + s.AllowedKeys = map[string]bool{"d": true, "i": true} + press(s, "d", "i", `"`) + if got := s.Buffer[0]; got != `say "" now` { + t.Errorf(`di" gated: buffer = %q, want %q`, got, `say "" now`) + } + + s = New([]string{"one", "two", "three"}, Pos{0, 0}) + s.AllowedKeys = map[string]bool{"d": true} + s.Press("d") + if ev := s.Press("3"); ev.Kind != EvInvalid { + t.Errorf("count digit after an armed operator should stay gated, got %v", ev.Kind) + } +} diff --git a/internal/engine/visual.go b/internal/engine/visual.go index 5ebe7f6..26d85aa 100644 --- a/internal/engine/visual.go +++ b/internal/engine/visual.go @@ -5,6 +5,11 @@ package engine // d/x/y/c on the selection, and esc/v to leave. // ponytail: no counts, f, or text objects in visual — add when a lesson needs them. func (s *Simulator) pressVisual(key string) Event { + // Visual commands are commands: the same AllowedKeys gate as normal mode + // applies (esc is always free, as everywhere). + if s.AllowedKeys != nil && !s.AllowedKeys[key] && key != "esc" { + return Event{EvInvalid} + } switch key { case "esc", "v": s.Mode = ModeNormal diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 9664e12..3bb7a35 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -248,6 +248,9 @@ func TestFinaleAwaitsTheFinalActBoss(t *testing.T) { last = i } } + if last == -1 { + t.Fatal("no lesson with a boss found") + } m.lessonIdx = last m.inBoss = true mm, _ := m.awardBoss() diff --git a/internal/ui/room.go b/internal/ui/room.go index b7595b4..0e927ca 100644 --- a/internal/ui/room.go +++ b/internal/ui/room.go @@ -328,14 +328,16 @@ func renderSelected(line string, row int, sim *engine.Simulator) string { if row < start.Row || row > end.Row { return line } - from, to := 0, len(line) + // Rune indices, matching the cursor-render path — never split a UTF-8 char. + r := []rune(line) + from, to := 0, len(r) if row == start.Row { - from = min(start.Col, len(line)) + from = min(start.Col, len(r)) } if row == end.Row { - to = min(end.Col+1, len(line)) + to = min(end.Col+1, len(r)) } - return line[:from] + cursorStyle.Render(line[from:to]) + line[to:] + return string(r[:from]) + cursorStyle.Render(string(r[from:to])) + string(r[to:]) } func (m Model) renderHUD(ch content.Challenge) string {