diff --git a/KEYS.md b/KEYS.md index fdf05b3..4f31e30 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,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` 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 @@ -341,6 +378,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..73135ba 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,13 @@ 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 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 The feature you'll press a hundred times a day. One keystroke drops you into a @@ -67,6 +74,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..9072750 --- /dev/null +++ b/docs/macos-cmd.md @@ -0,0 +1,153 @@ +# 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 | `[122;10u` | +| 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`): + +```ini +keybind = cmd+z=unbind +keybind = cmd+shift+z=unbind +``` + +`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 + +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..e2bb1fa 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,130 @@ 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 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", "c" }) do + pcall(map, mode, k, "", S) + end +end +for _, k in ipairs({ "", "" }) do + for _, mode in ipairs({ "n", "i", "x", "s", "t", "c" }) 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..346d108 --- /dev/null +++ b/tests/keycheck.sh @@ -0,0 +1,58 @@ +#!/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) +# '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 +# 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 (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 TERM QUIT HUP +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..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,20 +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() + 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 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..13f1809 100755 --- a/tests/suite_options.sh +++ b/tests/suite_options.sh @@ -121,6 +121,52 @@ 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) +-- 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 +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.