From baf4f70dc8e1588a7097439f835de60b654afa57 Mon Sep 17 00:00:00 2001 From: Jainal Gosaliya Date: Sat, 15 Aug 2026 09:35:15 -0400 Subject: [PATCH 1/5] Give the Mac hand its keys: Cmd chords, a backspace that comes home, a real Shift+Tab Three things a Mac hand hits in its first minute, all measured on a real pty: * Every editor chord is now bound on Cmd too -- , Cmd+Shift+Z for redo, Cmd+/ in all its spellings, Cmd+Q for the quit prompt, Cmd+arrows and Cmd+Backspace on the built-ins keymodel already governs, and Cmd+V into the REPL. A terminal program never sees Cmd by default -- the terminal owns it -- but nvim decodes CSI-u super chords with no negotiation at all, so the moment a terminal forwards them (iTerm2 3.5+ is one setting, scoped to the kitty protocol so the shell keeps its Cmd) the whole set is live. Every OTHER printable Cmd chord is floored to : unmapped, a forwarded Cmd+W typed the literal text "" into the buffer, replaced a selection with it, and ate a character in normal mode. docs/macos-cmd.md has the per- terminal recipes; tests/keycheck.sh prints what a terminal actually sends. * Backspace out of the click-anywhere ghost space used to inch back one invisible column per press with the text untouched -- measured, virtcol 40, 39, 38 with the line identical -- which read as "backspace does not move the cursor back". One press now snaps the caret to the end of the real text, the next deletes for real. Ordinary backspace is untouched. * Shift+Tab in insert dedents the current line. Unmapped it fell back to plain Tab and INDENTED. i_CTRL-D rather than the selection engine, so it also takes one level off the whitespace-only line the autoindent just gave you. Co-Authored-By: Claude Fable 5 --- KEYS.md | 47 ++++++++++- README.md | 11 +++ docs/macos-cmd.md | 152 +++++++++++++++++++++++++++++++++++ nvimrc.lua | 178 ++++++++++++++++++++++++++++++++++++++++- tests/keycheck.sh | 47 +++++++++++ tests/mouse_drive.py | 39 +++++++++ tests/suite_config.sh | 89 +++++++++++++++++++++ tests/suite_options.sh | 41 ++++++++++ 8 files changed, 597 insertions(+), 7 deletions(-) create mode 100644 docs/macos-cmd.md create mode 100755 tests/keycheck.sh diff --git a/KEYS.md b/KEYS.md index fdf05b3..37f5f95 100644 --- a/KEYS.md +++ b/KEYS.md @@ -19,7 +19,7 @@ Syntax highlighting only. No completion, no LSP, no snippets, no AI. | `Ctrl+Y` | redo | normal, insert, visual | | `Ctrl+Shift+Z` | redo (needs a terminal that speaks CSI-u) | normal, insert, visual | | `Tab` | indent the selected lines | **selection only** | -| `Shift+Tab` | unindent the selected lines | **selection only** | +| `Shift+Tab` | unindent the selected lines — or, in insert, the line you are on | selection, insert | | `Ctrl+Q` | visual block (was `Ctrl+V`) | normal, visual | | `Ctrl+Shift+Q` | **quit, with a confirmation** | normal, insert, **and inside the REPL** — except on a terminal with no CSI-u, where **normal mode is not covered**; see below | | **Shift+arrows** | **select, like any other editor** | normal, insert | @@ -75,6 +75,16 @@ walks into the empty space past it instead of stopping. Delete the `vim.opt.virtualedit` line to get the old behaviour back, at the price of the click snapping again. +**Backspace out of the empty space walks you home.** A click past the end of a +line parks the caret where there are no characters under it, and vim's own +backspace out there inched back one ghost column per press with the text +untouched — thirty presses of nothing visible before the first real character +would go, which read as "backspace does not move the cursor back". Now one +press snaps the caret to the end of the real text, and the next one deletes +for real. On a blank line it snaps to column 1 first, then joins upward. +Backspace anywhere else — mid-word, after typing into the padded gap, at a +line start — is vim's own, untouched. + **Option+click is the same click.** (Alt+click on Linux and WSL — same key, same handling; the explanation below is macOS because that is the habit it accommodates.) macOS habit — it is how iTerm2 moves the @@ -132,6 +142,29 @@ edit, the next one, and the one after, until you happened to run another search. `Ctrl+A` selects the file in **Select** mode, so the next thing you type — a letter, `Delete`, `Backspace` — replaces the lot and leaves you typing. +## macOS: the same keys on Cmd + +Every chord above lives on Ctrl because a program running in a terminal never +sees the Cmd key — the terminal emulator owns it (Cmd+C *is* the terminal's +Copy, Cmd+Q quits the terminal), which is why every terminal editor is a Ctrl +editor on a Mac. Two habits work regardless, with zero setup: **Cmd+V already +pastes** — the terminal pastes, bracketed, into the file and the REPL alike — +and **Shift+drag then Cmd+C** copies through the terminal's own selection. + +A terminal that *forwards* Cmd chords gets the whole set on Cmd too — iTerm2 +3.5+ is one setting (scoped so your shell prompt keeps normal Cmd), kitty and +Ghostty unmap theirs per chord, Terminal.app cannot do it at all. Forwarded, +you get `Cmd+S/C/X/V/A/F/Z//` plus **`Cmd+Shift+Z`** for redo, **`Cmd+Q`** +for the quit prompt, **`Cmd+arrows`** for line/file ends (shifted: selecting) +and **`Cmd+Backspace`** to delete to the line start — with two deliberate +mac-isms: insert-mode `Cmd+C` copies the *line* rather than acting as Esc +(that job stays on `Ctrl+C`), and `Cmd+V` also pastes inside the REPL, where +`Ctrl+V` is left to python. Every *other* Cmd chord is deliberately +swallowed: unmapped, a forwarded `Cmd+W` would type the literal text `` +into your file (measured). Setup recipes: +[docs/macos-cmd.md](docs/macos-cmd.md), and `tests/keycheck.sh` prints what +your terminal actually sends when you press one. + ## Still vim Everything else is untouched: `hjkl`, `w/b/e`, `dd`, `yy`, `p`, `ciw`, `.`, @@ -159,9 +192,10 @@ type code -> Ctrl+E -> typing at >>> -> Ctrl+E -> back in the file, in I You never press `i`, and you never press Esc. Landing in a live interpreter puts you at the prompt; landing back on the file puts you in insert. `Ctrl+W j` / `Ctrl+W k` and mouse clicks follow the same rule — *leaving the file*. They do -not work leaving the **interpreter**: nothing but `Ctrl+E` and `Ctrl+Shift+Q` is -bound in terminal mode, deliberately, so `Ctrl+W` goes to python as a word-erase -and you stay put. Out of the REPL it is `Ctrl+E`, `Ctrl+\` `Ctrl+N`, or a click. +not work leaving the **interpreter**: nothing but `Ctrl+E` and `Ctrl+Shift+Q` — +plus `Cmd+V` and `Cmd+Q` where the terminal forwards Cmd (see +[docs/macos-cmd.md](docs/macos-cmd.md)) — is bound in terminal mode, +deliberately, so `Ctrl+W` goes to python as a word-erase and you stay put. Out of the REPL it is `Ctrl+E`, `Ctrl+\` `Ctrl+N`, or a click. **The interpreter always has the code you can see.** Edited the file since it started? `Ctrl+E` restarts python with the new code. Didn't touch it? Same @@ -341,6 +375,11 @@ Every one of these was a real collision, not a hypothetical. Measured — two tabs on a two-line selection indented one line by 8 and the other by 4. It uses the buffer API instead, same rule as `Ctrl+/`: change the lines, never the mode. Output is byte-identical to `:>` otherwise. + Insert-mode `Shift+Tab` is the one later addition: it dedents the current + line via vim's own `i_CTRL-D` rather than this engine — the engine's + blank-lines-stay-blank rule (right for a block) would skip the + whitespace-only line the autoindent just gave you, which mid-typing is + exactly the line you want dedented. 11. **Clearing the search highlight from an autocmd silently does nothing.** An autocmd body runs inside a save/restore of the search state, so writing diff --git a/README.md b/README.md index e07aa8a..ee6a508 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ You're in the editor, in a file that didn't exist a second ago, cursor blinking, already in insert mode. No path to remember, no project to scaffold, no config to answer. Just type. +**One optional extra step on macOS:** flip a single iTerm2 setting +(*Settings → Profiles → Keys → General → "Left Command key" → Super*) and +every drill shortcut is on **Cmd** too — Cmd+C, Cmd+V, Cmd+Z, the keys your +hand already knows — while the shell prompt keeps its normal Cmd. Details and +other terminals: [docs/macos-cmd.md](docs/macos-cmd.md). + ## `Ctrl+E` — your code, live The feature you'll press a hundred times a day. One keystroke drops you into a @@ -67,6 +73,11 @@ Every key does what your fingers already expect: `Ctrl+Z` undo · `Ctrl+Y` redo · `Ctrl+F` find · `Ctrl+/` comment · **Shift+arrows select** · `Tab`/`Shift+Tab` indent · `Delete` clears a selection +Ctrl, not Cmd, because a terminal never hands the Cmd key to what runs inside +it — but the same chords are bound on Cmd too, live the moment your terminal +forwards them. iTerm2 does it with one setting: +[docs/macos-cmd.md](docs/macos-cmd.md). + And underneath it is still real Neovim: `hjkl`, `dd`, `ciw`, macros and `:%s/…` all work the moment you want them. diff --git a/docs/macos-cmd.md b/docs/macos-cmd.md new file mode 100644 index 0000000..e35ed40 --- /dev/null +++ b/docs/macos-cmd.md @@ -0,0 +1,152 @@ +# Cmd on a Mac + +drill's chords live on **Ctrl** — `Ctrl+C`, `Ctrl+V`, `Ctrl+Z` — and on a Mac +that looks like a mistake until you know the constraint: **a program running +in a terminal never sees the Cmd key.** The terminal emulator owns it. Cmd+C +is the terminal's own Copy, Cmd+V its Paste, Cmd+A selects the scrollback, +Cmd+Q quits the terminal itself — each one is handled (or swallowed) before +nvim gets a byte, and the classic terminal encoding has no way to spell a Cmd +chord at all. That is why every editor that lives in a terminal — vim, emacs, +nano, micro — is a Ctrl editor, even on a Mac. + +Two Cmd habits survive that with **zero setup**, which is why they already +work in drill: + +- **Cmd+V pastes.** The terminal pastes your clipboard into the session as + input, drill speaks bracketed paste, so indented Python lands unstaircased — + in the file and at the `>>>` prompt alike. +- **Shift+drag, then Cmd+C** copies with the terminal's own selection (Shift + is the modifier this config deliberately leaves to the terminal). + +Everything else — Cmd+Z, Cmd+A, Cmd+/ — dies at the terminal. The Ctrl set is +the one drill can promise everywhere. + +## The part that is fixable + +The CSI-u / kitty keyboard protocol *can* encode a Cmd chord (Cmd is the +"super" modifier, bit 8 — Cmd+Z is `ESC [ 122;9 u`), and nvim decodes those +sequences unconditionally — measured on this config: feeding the raw bytes +fires a `` mapping with no protocol negotiation at all. The only missing +piece is a terminal willing to **send** them instead of eating the chord. + +So drill binds every editor chord on Cmd too, same modes, same handlers: + +| Forwarded chord | Does | +|---|---| +| `Cmd+S` | save | +| `Cmd+C` | copy the selection — **with no selection, the line** (VS Code habit; insert-mode `Cmd+C` is *not* Esc — that job stays on `Ctrl+C`) | +| `Cmd+X` | cut the selection | +| `Cmd+V` | paste — including in the REPL, where `Ctrl+V` is left to python | +| `Cmd+A` | select all | +| `Cmd+F` | find | +| `Cmd+Z` | undo | +| `Cmd+Shift+Z` | redo (the mac redo; `Ctrl+Y` still works everywhere) | +| `Cmd+/` | comment / uncomment | +| `Cmd+Q` | quit, with the same confirmation as `Ctrl+Shift+Q` | +| `Cmd+←` / `Cmd+→` | start / end of line | +| `Cmd+↑` / `Cmd+↓` | start / end of file | +| `Cmd+Shift+arrows` | the same four motions, selecting | +| `Cmd+Backspace` | delete to the start of the line | + +The cursor chords ride nvim's own ``/``/``/``, +which are already in drill's `keymodel`, so the shifted forms select exactly +like Shift+arrows and typing over that selection replaces it. + +**Every other Cmd chord is swallowed.** This is load-bearing, not tidiness: +an *unmapped* forwarded chord does not die quietly in nvim — measured, a bare +`` typed the literal text `` into the buffer in insert, replaced +the selection with it in Select mode, and ate a character in normal mode. So +a reflexive Cmd+W or Cmd+B from a mac hand would spray key notation into +your file. drill floors every printable Cmd chord to a no-op — in the REPL +too, where the junk would have gone to python — and binds the useful ones on +top. + +On a terminal that forwards nothing, all of this is inert and costs nothing. +`Ctrl+E` and `Ctrl+R` stay Ctrl-only on purpose: they are drill's own keys, +not system chords, and keeping them in one place keeps the muscle memory +portable. + +**Check what your terminal actually sends** at any point with +`~/drill/tests/keycheck.sh` — run it in the window you drill in, press the +chord, read the bytes. A forwarded Cmd+Z reads `^[[122;9u`. + +## iTerm2 — one extra step after install + +1. Open **Settings → Profiles →** *your profile* **→ Keys → General**. +2. Set **"Left Command key"** to **Super**. +3. Open drill and press `Cmd+Z`. If it does not undo, run + `~/drill/tests/keycheck.sh` in that window, press the chord, and read + what actually arrived. + +Per iTerm2's documentation this remap is live **only while the running +program uses the kitty keyboard protocol** — nvim negotiates it, your shell +does not — so inside drill, Cmd chords arrive as `` keys, and at the +prompt Cmd is still ordinary macOS Cmd: Cmd+V pastes, Cmd+Q quits, nothing +about the rest of your terminal life changes. Needs iTerm2 3.5+; leave the +*Right* Command key alone and you keep a stock Cmd on one thumb at all times. + +Prefer cherry-picking chords instead? **Settings → Profiles → Keys → Key +Mappings → +**, action **"Send Escape Sequence"**, one mapping per chord (the +leading ESC is added for you): + +| Chord | Esc+ | +|---|---| +| Cmd+Z | `[122;9u` | +| Cmd+Shift+Z | `[90;9u` | +| Cmd+/ | `[47;9u` | +| Cmd+A | `[97;9u` | +| Cmd+C | `[99;9u` | +| Cmd+X | `[120;9u` | +| Cmd+V | `[118;9u` | +| Cmd+S | `[115;9u` | +| Cmd+F | `[102;9u` | +| Cmd+← / Cmd+→ | `[1;9D` / `[1;9C` | +| Cmd+↑ / Cmd+↓ | `[1;9A` / `[1;9B` | +| Cmd+Backspace | `[127;9u` | + +(For a Cmd+Shift+arrow, the modifier is `10` instead of `9`.) + +The cost of this second route: key mappings are per-profile, not per-program, +so a remapped chord sends those bytes at the shell prompt too. Map Cmd+Z and +Cmd+/ (which the shell never needed) and skip Cmd+C/Cmd+V (whose terminal +versions you probably want to keep) and the trade mostly disappears. + +## kitty + +kitty owns Cmd chords through its own keybindings; an **unmapped** chord is +reported to a program that speaks the keyboard protocol. So free the ones you +want drill to have, in `~/.config/kitty/kitty.conf`: + +``` +map cmd+z +map cmd+shift+z +map cmd+/ +map cmd+a +``` + +A bare `map` with no action removes kitty's binding. Recent kitty can also +scope an unmap to particular windows (`map --when-focus-on ...`) if you want +a chord back at the shell — see kitty's mapping documentation. + +## Ghostty + +Unbind Ghostty's own use of a chord and it can reach the program +(`~/.config/ghostty/config`): + +``` +keybind = cmd+z=ignore +keybind = cmd+shift+z=ignore +``` + +`ignore` consumes Ghostty's default action so the chord is not taken; note +that macOS intercepts *menu* shortcuts before Ghostty sees them, so a few +chords (Cmd+C among them) may need their menu equivalent changed in System +Settings → Keyboard → Keyboard Shortcuts → App Shortcuts. `keycheck.sh` is +the arbiter of what actually got through. + +## Terminal.app + +Cannot do this. Apple's Terminal speaks no CSI-u at all — the same reason +`Ctrl+Shift+Z` and `Ctrl+Shift+Q` do not exist there — and its keyboard +mapping UI refuses plain Cmd chords. Use the Ctrl set, or a different +terminal. diff --git a/nvimrc.lua b/nvimrc.lua index ba55047..4284807 100644 --- a/nvimrc.lua +++ b/nvimrc.lua @@ -335,10 +335,11 @@ map("n", "", "/", L) -- find -- EXACTLY where you were typing, so the pre- spot is stashed here and -- consumed there. local search_from = nil -map("i", "", function() +local function find_from_insert() search_from = vim.api.nvim_win_get_cursor(0) return "/" -end, { expr = true, silent = false }) +end +map("i", "", find_from_insert, { expr = true, silent = false }) -- ...and the highlight goes away the moment you start typing again. -- 'hlsearch' is ON by default in nvim and this config never set it, so every @@ -781,6 +782,18 @@ end map({ "x", "s" }, "", function() shift_lines(1) end, S) map({ "x", "s" }, "", function() shift_lines(-1) end, S) +-- ...and in INSERT, Shift+Tab dedents the line you are on, the way it does in +-- every other editor. Unmapped, in insert falls back to plain +-- and INDENTS -- the exact opposite of what the shifted chord promises. Plain +-- Tab is untouched (you are writing Python; it has to go on typing +-- indentation). i_CTRL-D rather than the block engine above, deliberately: +-- vim's own "one shiftwidth off the front" works from any column, tracks the +-- caret, and -- unlike shift_lines, whose blank-lines-stay-blank rule exists +-- for block selections -- it also takes one level off the whitespace-only +-- line the autoindent just gave you, which mid-typing is exactly the line you +-- want it to work on. +map("i", "", "", S) + -- CONFLICT 3: is suspend by default. Mapped in every mode that can reach -- nvim's own suspend, so the editor cannot be accidentally backgrounded. map({ "n", "v", "i" }, "", "undo", S) @@ -803,9 +816,42 @@ map({ "n", "v", "i" }, "", "redo", S) map("n", "", '"_x', S) for _, k in ipairs({ "", "" }) do map("x", k, '"_c', S) - map("s", k, '"_c', S) -- insert-mode / are already right + map("s", k, '"_c', S) -- insert-mode is already right end +-- --------------------------------------------------------------------------- +-- Backspace OUT of the empty space -- the other half of clicking anywhere +-- +-- virtualedit=all lets a click land past the end of a line, and vim's own +-- insert-mode then walks back ONE GHOST COLUMN per press with the text +-- untouched. Measured: click "def f(n):" at virtcol 40 and three presses read +-- virtcol 39, 38, 37, the line identical throughout -- thirty presses of +-- nothing visible before the first real character would go. On screen that +-- reads as "backspace does not move the cursor back". +-- +-- A backspace out in the ghost space means "I did not mean to be out here". +-- So ONE press snaps the caret back to the end of the real text, and the next +-- press deletes for real -- exactly where a click past EOL would have put you +-- in an editor with no virtual space, except here you spend one keystroke +-- instead of losing the click-anywhere feature. +-- +-- Only PAST-EOL ghost space qualifies: col('.') == col('$') with coladd > 0. +-- The coladd of a caret sitting INSIDE a tab's width (also virtualedit) has +-- col('.') < col('$') and falls through to the built-in, as does every +-- ordinary backspace (coladd == 0) -- typing, autoindent, joining a line onto +-- the one above. The fed is mode "n": remapping it through THIS mapping +-- again would recurse. +-- --------------------------------------------------------------------------- +map("i", "", function() + local p = vim.fn.getpos(".") -- {buf, line, col, coladd} + if (p[4] or 0) > 0 and p[3] == vim.fn.col("$") then + vim.fn.cursor(p[2], vim.fn.col("$"), 0) -- back to the real text + return + end + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("", true, false, true), "n", false) +end, S) + -- ...and the same for typing over a selection. Select mode replaces it for -- free, but the replaced text would land in the system clipboard, so every -- printable key goes through the black hole too. @@ -1366,3 +1412,129 @@ map({ "n", "i", "t" }, "", quit_drill, S) if IS_WSL then map({ "i", "t" }, "", quit_drill, S) end + +-- --------------------------------------------------------------------------- +-- macOS: the same chords on Cmd -- for terminals that forward it +-- +-- Everything above lives on Ctrl for one reason: a program running in a +-- terminal never SEES the Cmd key. The terminal emulator owns it -- Cmd+C is +-- the terminal's own Copy, Cmd+V its Paste, Cmd+Q quits it -- and the legacy +-- encoding has no byte for a Cmd chord at all, so there was never anything to +-- bind. That is why every editor that lives in a terminal is a Ctrl editor, +-- even on a Mac. +-- +-- CSI-u fixed the second half of that: super is modifier bit 8, so Cmd+Z can +-- be encoded as ESC [ 122;9 u, and nvim decodes those UNCONDITIONALLY -- +-- measured on this machine, feeding the raw bytes fired a mapping with +-- no protocol negotiation at all. What no terminal does is SEND them by +-- default. iTerm2 3.5+ will with one setting (Settings > Profiles > Keys > +-- General > "Left Command key: Super" -- live only while the running program +-- speaks the kitty protocol, so the shell prompt keeps its normal Cmd), kitty +-- and Ghostty per chord by unmapping their own. docs/macos-cmd.md has the +-- recipes; tests/keycheck.sh prints what your terminal actually sends. +-- +-- So every editor chord is bound on too -- same modes, same handlers. +-- On a terminal that forwards nothing, these mappings simply never fire. And +-- there is no legacy-fallback problem to manage here (the / +-- dance): with no legacy byte to degrade to, a Cmd chord arrives whole or not +-- at all. +-- +-- Three deliberate differences from the Ctrl set, each one a macOS meaning: +-- * insert-mode Cmd+C is NOT Esc. On a Mac that chord means Copy and +-- nothing else -- so with no selection it copies the LINE, the way +-- VS Code and Sublime do. keeps the Esc job. +-- * Cmd+Shift+Z redoes -- THE mac redo -- alongside and . +-- * Cmd+Q asks the same "Quit drill?" as : a FORWARDED Cmd+Q was +-- aimed at the program you are looking at, and that is drill. Cancel is +-- still the default, and a terminal that keeps Cmd+Q for itself never +-- sends it here in the first place. +-- Also terminal-mode Cmd+V pastes into the REPL: there is left to +-- python, and a terminal that forwards Cmd has taken its own paste away, so +-- the chord must not go dead in the one window you paste tracebacks into. +-- nvim_paste routes it through the job, bracketed-paste aware. + +-- FIRST, a floor under every OTHER Cmd chord. A terminal that forwards Cmd +-- forwards ALL of it -- iTerm2's Super setting is not per-key -- and an +-- UNMAPPED in nvim does not die quietly. Measured over the wire: +-- insert typed the literal text "" into the buffer, Select replaced the +-- selection with it, and normal mode executed the raw spelling as commands +-- and ate a character. So one reflexive Cmd+W or Cmd+B from a mac hand would +-- spray notation into the file. Every printable Cmd chord is floored to +-- here -- including terminal mode, where the junk would have gone to +-- python -- and the chords with real jobs are bound AFTERWARDS, overwriting +-- their slot. pcall, because a couple of punctuation spellings (">") may +-- not parse on every nvim; a skipped floor there is the pre-existing +-- behaviour, not a regression. +local D_NAMED = { [32] = "", [60] = "", [124] = "" } +for i = 32, 126 do + local k = D_NAMED[i] or ("") + for _, mode in ipairs({ "n", "i", "x", "s", "t" }) do + pcall(map, mode, k, "", S) + end +end +for _, k in ipairs({ "", "" }) do + for _, mode in ipairs({ "n", "i", "x", "s", "t" }) do + pcall(map, mode, k, "", S) + end +end + +-- The mac cursor chords, on the built-ins that already speak this config's +-- selection model: /// and their shifted forms are +-- all in 'keymodel's startsel/stopsel list, so Cmd+Shift+arrow extends a +-- Select-mode selection and a plain Cmd+arrow collapses one, exactly like +-- the bare arrows. noremap, so these reach the built-in, not a mapping. +for _, m in ipairs({ + { "", "" }, { "", "" }, + { "", "" }, { "", "" }, + { "", "" }, { "", "" }, + { "", "" }, { "", "" }, +}) do + map({ "n", "i", "x", "s" }, m[1], m[2], S) +end +-- Cmd+Backspace deletes to the start of the line, as everywhere on a Mac. +-- i_CTRL-U, so what it deletes does not overwrite the clipboard. +map("i", "", "", S) + +local function copy_line() + local line = { vim.api.nvim_get_current_line() } + vim.fn.setreg("+", line, "V") + vim.fn.setreg('"', line, "V") +end + +map({ "n", "v", "i" }, "", save, S) + +map({ "x", "s" }, "", copy_selection, S) +map({ "n", "i" }, "", copy_line, S) + +map("x", "", '"+d', S) +map("s", "", '"+d', S) + +map("n", "", '"+p', S) +map("i", "", "+", S) +map("c", "", "+", L) +map("x", "", '"_c+', S) +map("s", "", '"_c+', S) +map("t", "", function() + local txt = vim.fn.getreg("+") + if txt ~= "" then vim.api.nvim_paste(txt, true, -1) end +end, S) + +map("n", "", "gggHG", S) +map("i", "", "gggHG", S) + +map("n", "", "/", L) +map("i", "", find_from_insert, { expr = true, silent = false }) + +map({ "n", "v", "i" }, "", "undo", S) +map({ "n", "v", "i" }, "", "redo", S) + +-- "" too: a terminal that encodes Cmd+Shift+/ as the shifted CODEPOINT +-- (ESC[63;9u) lands on that spelling, and the floor above must not be +-- what catches it. +for _, key in ipairs({ "", "", "" }) do + for _, mode in ipairs({ "n", "i", "x", "s" }) do + map(mode, key, toggle_comment, S) + end +end + +map({ "n", "i", "t" }, "", quit_drill, S) diff --git a/tests/keycheck.sh b/tests/keycheck.sh new file mode 100755 index 0000000..5201db7 --- /dev/null +++ b/tests/keycheck.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# ============================================================================ +# keycheck.sh -- does this terminal forward Cmd chords to the program at all? +# +# Run it IN the window you drill in, then press the chord you are wondering +# about (say Cmd+Z). It prints the raw bytes that arrived. If nothing +# arrives, nothing nvim can do will help -- the chord never left the +# terminal, and docs/macos-cmd.md is the page that fixes that. +# +# It pushes the kitty keyboard protocol itself (CSI > 1 u) before reading, +# because that is the state nvim runs in -- and iTerm2's "Left Command key: +# Super" setting only forwards Cmd WHILE a program speaks the protocol, so +# testing at a bare prompt would wrongly say "nothing arrives". +# ============================================================================ +printf '\n' +printf 'TERM = %s\n' "${TERM:-}" +printf 'TMUX = %s\n' "${TMUX:-}" +printf '\n' + +cleanup() { printf '\033[/dev/null; } +SAVED=$(stty -g) +trap cleanup EXIT INT + +printf '\033[>1u' # the protocol nvim negotiates +stty raw -echo + +printf 'Press ONE Cmd chord now, e.g. Cmd+Z (waiting 6 seconds)...\r\n' +BYTES=$(dd bs=1 count=32 2>/dev/null <&0 & sleep 6; kill $! 2>/dev/null; wait 2>/dev/null) + +cleanup; trap - EXIT INT +printf '\n\n' +if [ -z "$BYTES" ]; then + printf 'RESULT: nothing arrived -- this terminal did NOT forward the chord.\n' + printf ' The terminal handled it itself (its own Copy, Paste, menu...).\n' + printf ' See docs/macos-cmd.md for the per-terminal setting that\n' + printf ' forwards Cmd, then run this again.\n' +else + printf 'RESULT: something arrived. Raw bytes:\n' + printf '%s' "$BYTES" | cat -v + printf '\n\n' + printf 'A forwarded Cmd chord reads ^[[;u -- Cmd+Z is ^[[122;9u.\n' + printf 'The mod is 1 + its bits: Shift 1, Option 2, Ctrl 4, Cmd 8 -- so 9\n' + printf 'is Cmd alone and 10 is Cmd+Shift. If you see that shape, drill'"'"'s\n' + printf ' mappings will fire. Anything else (a bare letter, ^C, ^V)\n' + printf 'means the terminal translated the chord instead of forwarding it.\n' +fi +printf '\n' diff --git a/tests/mouse_drive.py b/tests/mouse_drive.py index b24b5ae..c695578 100755 --- a/tests/mouse_drive.py +++ b/tests/mouse_drive.py @@ -227,6 +227,45 @@ def main(): got == "def f(n):" + " " * 30 + "#", repr(got)) v.reset() + # ---- backspace OUT of the empty space ---------------------------- + # The other half of clicking anywhere. Vim's own insert walked + # back ONE ghost column per press -- virtcol 40, 39, 38... -- with the + # text untouched, which on screen read as "backspace does not move the + # cursor back". One press now snaps the caret to the end of the real + # text; the next deletes for real. 0x7f is what the big delete key + # sends under TERM=xterm-256color. + v.normal() + v.click(GUT + 40, 1) # ghost space after "def f(n):" + v.send("\x7f", 0.30) + ok("bs_ghost_snaps_to_text", v.q('virtcol(".")') == "10", v.q('virtcol(".")')) + ok("bs_ghost_deletes_nothing", v.q("getline(1)") == "def f(n):", + repr(v.q("getline(1)"))) + ok("bs_ghost_stays_insert", v.q("mode(1)") == "i", v.q("mode(1)")) + v.send("\x7f", 0.30) + ok("bs_after_snap_deletes", v.q("getline(1)") == "def f(n)", + repr(v.q("getline(1)"))) + v.reset() + + # on a blank line: snap to column 1 first, and only THEN join up + v.normal() + v.click(GUT + 26, 6) # line 6 of the fixture is EMPTY + v.send("\x7f", 0.30) + ok("bs_blank_snaps_to_col1", v.q('virtcol(".")') == "1", v.q('virtcol(".")')) + ok("bs_blank_line_count_kept", v.q("line('$')") == "7", v.q("line('$')")) + v.send("\x7f", 0.30) + ok("bs_blank_then_joins", v.q("line('$')") == "6", v.q("line('$')")) + v.reset() + + # an ORDINARY backspace is untouched: type into the padded gap and + # deletes the character you just typed, nothing else + v.normal() + v.click(GUT + 20, 1) + v.send("#", 0.30) + v.send("\x7f", 0.30) + ok("bs_after_typing_still_deletes", + v.q("getline(1)") == "def f(n):" + " " * 10, repr(v.q("getline(1)"))) + v.reset() + # ---- a blank line, clicked way out to the right ------------------ # The case that made the mouse look broken: a half-written drill file # is mostly blank lines, and every click on one used to snap to column diff --git a/tests/suite_config.sh b/tests/suite_config.sh index cbc5c98..7cc463a 100755 --- a/tests/suite_config.sh +++ b/tests/suite_config.sh @@ -97,6 +97,22 @@ t tab_mixed_indent_matches_vim --ext py \ t insert_tab_still_types_indent --ext py \ --content 'x' --cursor 1:1 --keys 'Z' --expect ' Zx' +# ...and Shift+Tab in INSERT dedents the line you are on (i_CTRL-D). +# Unmapped it fell back to plain and INDENTED -- the opposite of the +# label. Works from any column, and on the whitespace-only line the +# autoindent just gave you, which the selection engine deliberately skips. +t stab_insert_dedents_current_line --ext py \ + --content ' a = 1' --keys '' --expect ' a = 1' + +t stab_insert_works_mid_line --ext py \ + --content ' a = 1' --cursor 1:9 --keys '' --expect 'a = 1' + +t stab_insert_takes_autoindent_off --ext py \ + --content ' ' --keys '' --expect '' + +t stab_insert_floors_at_column_zero --ext py \ + --content 'a = 1' --keys 'Z' --expect 'Za = 1' + echo "=== cut / paste / select-all ===" t cut_ctrl_x_in_select \ @@ -134,6 +150,79 @@ t redo_ctrl_y_still_works \ t undo_then_no_redo_stays_undone \ --content 'base' --keys 'XY' --expect 'base' +echo +echo "=== the same chords on Cmd (a mac terminal that forwards it) ===" +# only ever arrives as CSI-u -- there is no legacy byte for a Cmd +# chord -- and nvim decodes those with no negotiation, so feeding the notation +# exercises exactly what a forwarding terminal delivers. docs/macos-cmd.md is +# the setup; these assert the editor side of the bargain. + +t cmd_z_undoes \ + --content 'base' --keys 'XY' --expect 'base' + +t cmd_shift_z_redoes \ + --content 'base' --keys 'XY' --expect 'XYbase' + +t cmd_a_then_type_replaces_all \ + --content 'alpha\nbravo\ncharlie' --keys 'Z' --expect 'Z' + +t cmd_slash_comments --ext py \ + --content 'a = 1' --keys '' --expect '# a = 1' + +t cmd_slash_roundtrips --ext py \ + --content 'a = 1' --keys '' --expect 'a = 1' + +t cmd_x_cuts \ + --content 'alpha bravo' --keys "${SEL5}" --expect ' bravo' --expect-reg '+=alpha' + +t cmd_c_copies_and_keeps_selection \ + --content 'alpha bravo' --keys "${SEL5}Z" --expect 'Z bravo' --expect-reg '+=alpha' + +# no selection: Cmd+C copies the LINE, the way VS Code and Sublime do. +# (Insert-mode Cmd+C is NOT Esc -- on a Mac that chord means Copy, full stop.) +t cmd_c_no_selection_copies_line \ + --content 'alpha bravo' --keys '' --expect 'alpha bravo' \ + --expect-reg '+=alpha bravo' + +t cmd_v_paste_roundtrip \ + --content 'alpha bravo' --keys "${SEL5}" --expect 'alpha bravo' + +t cmd_v_over_selection \ + --content 'one two' --keys '' \ + --expect 'onetwo' + +# the mac cursor chords ride the built-ins (///), +# which are all in 'keymodel', so the shifted forms select like shift+arrows +t cmd_left_is_line_start \ + --content 'alpha bravo' --cursor 1:6 --keys 'Z' --expect 'Zalpha bravo' + +t cmd_right_is_line_end \ + --content 'alpha bravo' --cursor 1:3 --keys 'Z' --expect 'alpha bravoZ' + +t cmd_up_is_start_of_file \ + --content 'alpha\nbravo' --cursor 2:3 --keys 'Z' --expect 'Zalpha\nbravo' + +t cmd_down_is_end_of_file \ + --content 'alpha\nbravo\ncharlie' --keys 'Z' \ + --expect 'alpha\nbravo\ncharlieZ' + +t cmd_shift_right_selects_to_eol \ + --content 'alpha bravo' --cursor 1:7 --keys 'Z' --expect 'alpha Z' + +t cmd_bs_deletes_to_line_start \ + --content 'alpha bravo' --start append --keys 'Z' --expect 'Z' + +# ...and every OTHER Cmd chord is floored to . Unmapped, a forwarded +# typed the literal text "" into the buffer (measured over the +# wire), replaced a selection with it in Select, and ate a character in +# normal -- so one reflexive Cmd+W from a mac hand would spray notation +# into the file. +t cmd_floor_swallows_unbound_chords \ + --content 'alpha' --keys 'Z' --expect 'Zalpha' + +t cmd_floor_keeps_selection_intact \ + --content 'alpha bravo' --keys "${SEL5}Z" --expect 'Z bravo' + echo echo "=== copies without losing the selection ===" diff --git a/tests/suite_options.sh b/tests/suite_options.sh index 6896c4f..3cf4a34 100755 --- a/tests/suite_options.sh +++ b/tests/suite_options.sh @@ -121,6 +121,47 @@ for _, m in ipairs({"n", "v", "i"}) do ok("redo_ctrl_shift_z_bound_" .. m, vim.fn.maparg("", m) ~= "", vim.fn.maparg("", m)) ok("undo_ctrl_z_bound_" .. m, vim.fn.maparg("", m) ~= "", vim.fn.maparg("", m)) end +-- the Cmd mirrors (docs/macos-cmd.md). They only ever arrive from a terminal +-- that forwards Cmd as CSI-u, and everywhere else they are inert -- but they +-- must be REGISTERED, or a forwarded chord falls through to the nvim +-- defaults and a bare types an "a" over your selection. +for _, m in ipairs({"n", "i"}) do + for _, k in ipairs({"", "", "", "", "", + "", "", "", ""}) do + ok("cmdkey_bound_" .. k .. "_" .. m, vim.fn.maparg(k, m) ~= "", vim.fn.maparg(k, m)) + end +end +for _, m in ipairs({"x", "s"}) do + for _, k in ipairs({"", "", "", ""}) do + ok("cmdkey_bound_" .. k .. "_" .. m, vim.fn.maparg(k, m) ~= "", vim.fn.maparg(k, m)) + end +end +-- the mac cursor chords ride the built-ins that keymodel already governs +for _, m in ipairs({"n", "i"}) do + for _, k in ipairs({"", "", "", "", + "", "", "", ""}) do + ok("cmdkey_bound_" .. k .. "_" .. m, vim.fn.maparg(k, m) ~= "", vim.fn.maparg(k, m)) + end +end +ok("cmdkey_bs_is_ctrl_u", vim.fn.maparg("", "i"):lower() == "", + vim.fn.maparg("", "i")) +-- every OTHER Cmd chord is floored to -- unmapped, a forwarded +-- typed the literal text "" into the buffer. Spot-check the floor in +-- every mode, terminal included (there the junk would have gone to python). +for _, m in ipairs({"n", "i", "x", "s", "t"}) do + ok("cmdfloor_" .. m, vim.fn.maparg("", m):lower() == "", + vim.fn.maparg("", m)) +end +-- terminal mode gets paste, quit and the floor and NOTHING else: Cmd+C stays +-- inert there so nothing is taken from python +for _, k in ipairs({"", ""}) do + ok("cmdkey_bound_" .. k .. "_t", vim.fn.maparg(k, "t") ~= "", vim.fn.maparg(k, "t")) +end +ok("cmdkey_c_floored_in_terminal", vim.fn.maparg("", "t"):lower() == "", + vim.fn.maparg("", "t")) +-- insert Shift+Tab dedent (i_CTRL-D), the pair of selection Tab/S-Tab +ok("stab_insert_bound", vim.fn.maparg("", "i"):lower() == "", + vim.fn.maparg("", "i")) -- quit. Bound in terminal too, so you can leave from inside the interpreter -- -- python has no use for . must stay VISUAL BLOCK: the two are only -- separate keys because CSI-u keeps Shift on a control chord. From ec0a101c339386546d12b8a8282ddfb7ff51bc2e Mon Sep 17 00:00:00 2001 From: Jainal Gosaliya Date: Sun, 16 Aug 2026 21:55:30 -0400 Subject: [PATCH 2/5] Give the command line the same Cmd floor as every other mode The floor covered n/i/x/s/t but not c, so a forwarded Cmd chord at the ':' or '/' prompt fell through as a stray character -- measured on a pty, ESC[119;9u (Cmd+W) left a literal "w" in getcmdline(). Drill opens that prompt itself: Cmd+F is a bare '/'. The cmdline paste is bound after the floor, so it still overwrites its slot; the widened spot-check covers the three spellings the generator has to name by hand, which are the ones that would regress quietly. Co-Authored-By: Claude Fable 5 --- nvimrc.lua | 13 +++++++------ tests/suite_options.sh | 13 +++++++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/nvimrc.lua b/nvimrc.lua index 4284807..e2bb1fa 100644 --- a/nvimrc.lua +++ b/nvimrc.lua @@ -1461,19 +1461,20 @@ end -- and ate a character. So one reflexive Cmd+W or Cmd+B from a mac hand would -- spray notation into the file. Every printable Cmd chord is floored to -- here -- including terminal mode, where the junk would have gone to --- python -- and the chords with real jobs are bound AFTERWARDS, overwriting --- their slot. pcall, because a couple of punctuation spellings (">") may --- not parse on every nvim; a skipped floor there is the pre-existing --- behaviour, not a regression. +-- python, and the command line, where a Cmd+W at the ':' prompt left a stray +-- "w" sitting in the command -- and the chords with real jobs are bound +-- AFTERWARDS, overwriting their slot. pcall, because a couple of punctuation +-- spellings (">") may not parse on every nvim; a skipped floor there is +-- the pre-existing behaviour, not a regression. local D_NAMED = { [32] = "", [60] = "", [124] = "" } for i = 32, 126 do local k = D_NAMED[i] or ("") - for _, mode in ipairs({ "n", "i", "x", "s", "t" }) do + for _, mode in ipairs({ "n", "i", "x", "s", "t", "c" }) do pcall(map, mode, k, "", S) end end for _, k in ipairs({ "", "" }) do - for _, mode in ipairs({ "n", "i", "x", "s", "t" }) do + for _, mode in ipairs({ "n", "i", "x", "s", "t", "c" }) do pcall(map, mode, k, "", S) end end diff --git a/tests/suite_options.sh b/tests/suite_options.sh index 3cf4a34..13f1809 100755 --- a/tests/suite_options.sh +++ b/tests/suite_options.sh @@ -147,10 +147,15 @@ ok("cmdkey_bs_is_ctrl_u", vim.fn.maparg("", "i"):lower() == "", vim.fn.maparg("", "i")) -- every OTHER Cmd chord is floored to -- unmapped, a forwarded -- typed the literal text "" into the buffer. Spot-check the floor in --- every mode, terminal included (there the junk would have gone to python). -for _, m in ipairs({"n", "i", "x", "s", "t"}) do - ok("cmdfloor_" .. m, vim.fn.maparg("", m):lower() == "", - vim.fn.maparg("", m)) +-- every mode, terminal included (there the junk would have gone to python) +-- and the command line (there it landed in the ':' prompt). The keys are the +-- spellings most likely to regress: the plain letter plus the three the +-- generator has to name by hand and a digit. +for _, m in ipairs({"n", "i", "x", "s", "t", "c"}) do + for _, k in ipairs({"", "", "", "", ""}) do + ok("cmdfloor_" .. k .. "_" .. m, vim.fn.maparg(k, m):lower() == "", + vim.fn.maparg(k, m)) + end end -- terminal mode gets paste, quit and the floor and NOTHING else: Cmd+C stays -- inert there so nothing is taken from python From a97fef7a835d69d79bc93e2ecad51aefbe623734 Mon Sep 17 00:00:00 2001 From: Jainal Gosaliya Date: Sun, 16 Aug 2026 21:56:22 -0400 Subject: [PATCH 3/5] Ghostty needs unbind, not ignore, and Cmd+Shift+Z is [122;10u Four places answered the same question -- what actually reaches nvim -- and got it wrong. Ghostty's `ignore` consumes a key and forwards nothing; `unbind` is the one that drops Ghostty's claim and lets the chord through. As written the recipe threw Cmd+Z away instead of undoing. The iTerm2 table spelled Cmd+Shift+Z `[90;9u`. That happens to work, because nvim folds and into one key, but it is not what the protocol says and it contradicted this file's own next paragraph: mod 10 is Cmd+Shift, and the key code stays unshifted at 122. The README promised "every drill shortcut" on Cmd. Ctrl+E and Ctrl+R are deliberately not, and the floor makes them , so a mac hand pressing Cmd+E got silence. KEYS.md said nothing but Ctrl+E and Ctrl+Shift+Q is bound in terminal mode, which now invites the reader to expect an unbound Cmd chord to reach python. Co-Authored-By: Claude Fable 5 --- KEYS.md | 11 +++++++---- README.md | 5 +++-- docs/macos-cmd.md | 19 ++++++++++--------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/KEYS.md b/KEYS.md index 37f5f95..4f31e30 100644 --- a/KEYS.md +++ b/KEYS.md @@ -192,10 +192,13 @@ type code -> Ctrl+E -> typing at >>> -> Ctrl+E -> back in the file, in I You never press `i`, and you never press Esc. Landing in a live interpreter puts you at the prompt; landing back on the file puts you in insert. `Ctrl+W j` / `Ctrl+W k` and mouse clicks follow the same rule — *leaving the file*. They do -not work leaving the **interpreter**: nothing but `Ctrl+E` and `Ctrl+Shift+Q` — -plus `Cmd+V` and `Cmd+Q` where the terminal forwards Cmd (see -[docs/macos-cmd.md](docs/macos-cmd.md)) — is bound in terminal mode, -deliberately, so `Ctrl+W` goes to python as a word-erase and you stay put. Out of the REPL it is `Ctrl+E`, `Ctrl+\` `Ctrl+N`, or a click. +not work leaving the **interpreter**: only `Ctrl+E` and `Ctrl+Shift+Q` — plus +`Cmd+V` and `Cmd+Q` where the terminal forwards Cmd (see +[docs/macos-cmd.md](docs/macos-cmd.md)) — do anything in terminal mode, and +every other forwarded Cmd chord is floored to `` so a stray one cannot +spray notation at python. `Ctrl+W` is untouched and still reaches python as a +word-erase, so you stay put. Out of the REPL it is `Ctrl+E`, `Ctrl+\` +`Ctrl+N`, or a click. **The interpreter always has the code you can see.** Edited the file since it started? `Ctrl+E` restarts python with the new code. Didn't touch it? Same diff --git a/README.md b/README.md index ee6a508..73135ba 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ to answer. Just type. **One optional extra step on macOS:** flip a single iTerm2 setting (*Settings → Profiles → Keys → General → "Left Command key" → Super*) and -every drill shortcut is on **Cmd** too — Cmd+C, Cmd+V, Cmd+Z, the keys your -hand already knows — while the shell prompt keeps its normal Cmd. Details and +every editor chord is on **Cmd** too — Cmd+C, Cmd+V, Cmd+Z, the keys your +hand already knows — while the shell prompt keeps its normal Cmd. `Ctrl+E` +and `Ctrl+R` stay on Ctrl on purpose: they are drill's own keys. Details and other terminals: [docs/macos-cmd.md](docs/macos-cmd.md). ## `Ctrl+E` — your code, live diff --git a/docs/macos-cmd.md b/docs/macos-cmd.md index e35ed40..9072750 100644 --- a/docs/macos-cmd.md +++ b/docs/macos-cmd.md @@ -92,7 +92,7 @@ leading ESC is added for you): | Chord | Esc+ | |---|---| | Cmd+Z | `[122;9u` | -| Cmd+Shift+Z | `[90;9u` | +| Cmd+Shift+Z | `[122;10u` | | Cmd+/ | `[47;9u` | | Cmd+A | `[97;9u` | | Cmd+C | `[99;9u` | @@ -133,16 +133,17 @@ a chord back at the shell — see kitty's mapping documentation. Unbind Ghostty's own use of a chord and it can reach the program (`~/.config/ghostty/config`): -``` -keybind = cmd+z=ignore -keybind = cmd+shift+z=ignore +```ini +keybind = cmd+z=unbind +keybind = cmd+shift+z=unbind ``` -`ignore` consumes Ghostty's default action so the chord is not taken; note -that macOS intercepts *menu* shortcuts before Ghostty sees them, so a few -chords (Cmd+C among them) may need their menu equivalent changed in System -Settings → Keyboard → Keyboard Shortcuts → App Shortcuts. `keycheck.sh` is -the arbiter of what actually got through. +`unbind` removes Ghostty's own binding so the chord passes through as a CSI-u +sequence -- `ignore` is the trap here, it swallows the key and forwards +nothing. Note also that macOS intercepts *menu* shortcuts before Ghostty sees +them, so a few chords (Cmd+C among them) may need their menu equivalent +changed in System Settings → Keyboard → Keyboard Shortcuts → App Shortcuts. +`keycheck.sh` is the arbiter of what actually got through. ## Terminal.app From d6b70394aee6f4018ca8b494e5f8a662065ae285 Mon Sep 17 00:00:00 2001 From: Jainal Gosaliya Date: Sun, 16 Aug 2026 22:01:21 -0400 Subject: [PATCH 4/5] Lift the virtual-space cases out of main CodeScene's delta gate caught main() growing 178 -> 204 lines with the new backspace cases. The ghost space is one feature -- clicking into it, typing there, backspacing out -- so it moves out whole, not just the new half: main() is 171 lines now, under where it started. The block is unchanged apart from its indentation, and all ten cases still pass on a real pty. keycheck.sh gets its signal keys back. 'raw' clears isig, so a Ctrl+C from someone giving up on the wait was read as a byte and reported as "something arrived". isig alone would have been worse than the bug: it also hands back QUIT, and an untrapped Ctrl+\ would have exited past the EXIT trap, leaving the caller in raw mode with the keyboard protocol still pushed. So the trap widens in the same breath. Co-Authored-By: Claude Fable 5 --- tests/keycheck.sh | 13 +++-- tests/mouse_drive.py | 112 +++++++++++++++++++++++-------------------- 2 files changed, 68 insertions(+), 57 deletions(-) diff --git a/tests/keycheck.sh b/tests/keycheck.sh index 5201db7..b2691c5 100755 --- a/tests/keycheck.sh +++ b/tests/keycheck.sh @@ -19,15 +19,20 @@ printf '\n' cleanup() { printf '\033[/dev/null; } SAVED=$(stty -g) -trap cleanup EXIT INT +# QUIT and HUP as well as INT: 'isig' below hands the terminal back the signal +# keys, and an untrapped one of those would exit without running the EXIT trap +# -- leaving the caller in raw mode with the protocol still pushed. +trap cleanup EXIT INT TERM QUIT HUP printf '\033[>1u' # the protocol nvim negotiates -stty raw -echo +# isig, because plain 'raw' clears it: Ctrl+C would then arrive as a byte in +# BYTES and the script would report "something arrived" for a user giving up. +stty raw -echo isig -printf 'Press ONE Cmd chord now, e.g. Cmd+Z (waiting 6 seconds)...\r\n' +printf 'Press ONE Cmd chord now, e.g. Cmd+Z (Ctrl+C aborts; waiting 6 seconds)...\r\n' BYTES=$(dd bs=1 count=32 2>/dev/null <&0 & sleep 6; kill $! 2>/dev/null; wait 2>/dev/null) -cleanup; trap - EXIT INT +cleanup; trap - EXIT INT TERM QUIT HUP printf '\n\n' if [ -z "$BYTES" ]; then printf 'RESULT: nothing arrived -- this terminal did NOT forward the chord.\n' diff --git a/tests/mouse_drive.py b/tests/mouse_drive.py index c695578..5b91cd5 100755 --- a/tests/mouse_drive.py +++ b/tests/mouse_drive.py @@ -180,6 +180,64 @@ def close(self): pass +def virtual_space_cases(v, GUT): + """The ghost space past the end of a line: clicking into it, typing + there, and backspacing back out.""" + # ---- past the end of the line ------------------------------------ + # This is what 'virtualedit' bought and it is the whole complaint: the + # caret has to stop at the column you POINTED at, not at the last + # character that happens to exist. col(".") is a BYTE index and cannot + # express empty space, so it still reads 9 out there -- virtcol(".") is + # the one that answers the question. + v.normal() + v.click(GUT + 40, 1) # "def f(n):" is 9 characters + ok("past_eol_caret_at_clicked_col", v.q('virtcol(".")') == "40", v.q('virtcol(".")')) + v.send("#", 0.30) + got = v.q("getline(1)") + ok("past_eol_types_at_clicked_col", + got == "def f(n):" + " " * 30 + "#", repr(got)) + v.reset() + + # ---- backspace OUT of the empty space ---------------------------- + # The other half of clicking anywhere. Vim's own insert walked + # back ONE ghost column per press -- virtcol 40, 39, 38... -- with the + # text untouched, which on screen read as "backspace does not move the + # cursor back". One press now snaps the caret to the end of the real + # text; the next deletes for real. 0x7f is what the big delete key + # sends under TERM=xterm-256color. + v.normal() + v.click(GUT + 40, 1) # ghost space after "def f(n):" + v.send("\x7f", 0.30) + ok("bs_ghost_snaps_to_text", v.q('virtcol(".")') == "10", v.q('virtcol(".")')) + ok("bs_ghost_deletes_nothing", v.q("getline(1)") == "def f(n):", + repr(v.q("getline(1)"))) + ok("bs_ghost_stays_insert", v.q("mode(1)") == "i", v.q("mode(1)")) + v.send("\x7f", 0.30) + ok("bs_after_snap_deletes", v.q("getline(1)") == "def f(n)", + repr(v.q("getline(1)"))) + v.reset() + + # on a blank line: snap to column 1 first, and only THEN join up + v.normal() + v.click(GUT + 26, 6) # line 6 of the fixture is EMPTY + v.send("\x7f", 0.30) + ok("bs_blank_snaps_to_col1", v.q('virtcol(".")') == "1", v.q('virtcol(".")')) + ok("bs_blank_line_count_kept", v.q("line('$')") == "7", v.q("line('$')")) + v.send("\x7f", 0.30) + ok("bs_blank_then_joins", v.q("line('$')") == "6", v.q("line('$')")) + v.reset() + + # an ORDINARY backspace is untouched: type into the padded gap and + # deletes the character you just typed, nothing else + v.normal() + v.click(GUT + 20, 1) + v.send("#", 0.30) + v.send("\x7f", 0.30) + ok("bs_after_typing_still_deletes", + v.q("getline(1)") == "def f(n):" + " " * 10, repr(v.q("getline(1)"))) + v.reset() + + def main(): if not CONFIG or not os.path.isfile(CONFIG): sys.stderr.write("mouse_drive.py: DRILL_CONFIG not a file: %r\n" % CONFIG) @@ -212,59 +270,7 @@ def main(): ok("normal_click_then_letter_TYPES", got == " x total += i", repr(got)) v.reset() - # ---- past the end of the line ------------------------------------ - # This is what 'virtualedit' bought and it is the whole complaint: the - # caret has to stop at the column you POINTED at, not at the last - # character that happens to exist. col(".") is a BYTE index and cannot - # express empty space, so it still reads 9 out there -- virtcol(".") is - # the one that answers the question. - v.normal() - v.click(GUT + 40, 1) # "def f(n):" is 9 characters - ok("past_eol_caret_at_clicked_col", v.q('virtcol(".")') == "40", v.q('virtcol(".")')) - v.send("#", 0.30) - got = v.q("getline(1)") - ok("past_eol_types_at_clicked_col", - got == "def f(n):" + " " * 30 + "#", repr(got)) - v.reset() - - # ---- backspace OUT of the empty space ---------------------------- - # The other half of clicking anywhere. Vim's own insert walked - # back ONE ghost column per press -- virtcol 40, 39, 38... -- with the - # text untouched, which on screen read as "backspace does not move the - # cursor back". One press now snaps the caret to the end of the real - # text; the next deletes for real. 0x7f is what the big delete key - # sends under TERM=xterm-256color. - v.normal() - v.click(GUT + 40, 1) # ghost space after "def f(n):" - v.send("\x7f", 0.30) - ok("bs_ghost_snaps_to_text", v.q('virtcol(".")') == "10", v.q('virtcol(".")')) - ok("bs_ghost_deletes_nothing", v.q("getline(1)") == "def f(n):", - repr(v.q("getline(1)"))) - ok("bs_ghost_stays_insert", v.q("mode(1)") == "i", v.q("mode(1)")) - v.send("\x7f", 0.30) - ok("bs_after_snap_deletes", v.q("getline(1)") == "def f(n)", - repr(v.q("getline(1)"))) - v.reset() - - # on a blank line: snap to column 1 first, and only THEN join up - v.normal() - v.click(GUT + 26, 6) # line 6 of the fixture is EMPTY - v.send("\x7f", 0.30) - ok("bs_blank_snaps_to_col1", v.q('virtcol(".")') == "1", v.q('virtcol(".")')) - ok("bs_blank_line_count_kept", v.q("line('$')") == "7", v.q("line('$')")) - v.send("\x7f", 0.30) - ok("bs_blank_then_joins", v.q("line('$')") == "6", v.q("line('$')")) - v.reset() - - # an ORDINARY backspace is untouched: type into the padded gap and - # deletes the character you just typed, nothing else - v.normal() - v.click(GUT + 20, 1) - v.send("#", 0.30) - v.send("\x7f", 0.30) - ok("bs_after_typing_still_deletes", - v.q("getline(1)") == "def f(n):" + " " * 10, repr(v.q("getline(1)"))) - v.reset() + virtual_space_cases(v, GUT) # ---- a blank line, clicked way out to the right ------------------ # The case that made the mouse look broken: a half-written drill file From 1fc7d8b3eb748a4f42dd0ce2bbef248d2a14d5d0 Mon Sep 17 00:00:00 2001 From: Jainal Gosaliya Date: Sun, 16 Aug 2026 22:20:38 -0400 Subject: [PATCH 5/5] Make the signal keys actually leave keycheck.sh "trap cleanup INT" was half a fix: bash runs the handler and then RESUMES the script, so Ctrl+C restored the terminal and then fell straight through to "RESULT: nothing arrived" -- reporting a deliberate abort as a terminal that failed to forward the chord. cleanup rides EXIT alone now and each signal exits (128+signo) to reach it, so it still runs exactly once. Measured on a pty, Ctrl+C during the wait: before this branch it was read as a byte and printed "something arrived. Raw bytes: ^C" with status 0; now the script stops at once with status 130 and prints no result at all. Co-Authored-By: Claude Fable 5 --- tests/keycheck.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/keycheck.sh b/tests/keycheck.sh index b2691c5..346d108 100755 --- a/tests/keycheck.sh +++ b/tests/keycheck.sh @@ -19,10 +19,16 @@ printf '\n' cleanup() { printf '\033[/dev/null; } SAVED=$(stty -g) -# QUIT and HUP as well as INT: 'isig' below hands the terminal back the signal -# keys, and an untrapped one of those would exit without running the EXIT trap -# -- leaving the caller in raw mode with the protocol still pushed. -trap cleanup EXIT INT TERM QUIT HUP +# 'isig' below hands the terminal back its signal keys, so they have to land +# somewhere: cleanup rides EXIT alone, and each signal EXITS to get there. A +# bare "trap cleanup INT" would not do -- bash runs the handler and then +# RESUMES the script, so a Ctrl+C would restore the terminal and then still +# report "nothing arrived", calling a deliberate abort a failed forward. +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 131' QUIT +trap 'exit 129' HUP printf '\033[>1u' # the protocol nvim negotiates # isig, because plain 'raw' clears it: Ctrl+C would then arrive as a byte in