From ccd3075cac77044498a8c35d2482c8b4cb8f56e7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:35:52 -0300 Subject: Backspace in a line's leading blanks goes back to the previous indent stop helix's delete_char_backward dedents: when everything before the cursor on its line is blank, it deletes back to the previous multiple of the indent width, a whole unit when already on one (a tab before the cursor still goes alone). pardes deleted one space, so O under an indented line left three of four, which is where helix-golf's function_into_class ended up. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 6 +++--- src/edit.zig | 15 +++++++++++++-- test/hxcases/cases.jsonl | 5 +++++ test/hxcases/goldens.jsonl | 5 +++++ test/hxcases/parity-waivers.jsonl | 1 + 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 23d63454..8023ba6e 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -129,7 +129,7 @@ language-backend queries, and the shell pipe. | `n` / `N` (any pane) | MOTION, not a jump: move the SELECTION to the next / previous look-able text and open NOTHING. Enter — the look chord — on what it leaves selected is what opens it | KEEP, do not touch; not in the corpus (helix's `n`/`N` walk regex search hits). What a step selects is the pane's GRAIN (`output_pane.Grain`, read in `Pardes.lookSpanIn`): in FREE TEXT — a terminal, a file, a PDF, a prose answer buffer — the largest whitespace-delimited run `look.resolve` can act on (`look.lookableSpan`, wrapper punctuation peeled off both ends), several to a line; in a RESULTS BUFFER one stop per ROW, the largest run its head resolves as (`look.lookableLineSpan`), because a row there IS one location and the words after it are the match rather than a second place to go; in a COMMAND list the whole line. The walk is a RING across PANES: every pane that has performed a Look, most recent first (`Pardes.look_src`), then the output buffers that have not, newest first, and only when both are empty the active pane. Exhausting a pane enters the next at its first (forward) / last (backward) span and the end wraps to the start, so `N` is the exact inverse of `n`. What it lands on becomes an EXPLICIT `vsel` with the cursor on its FIRST column, in the pane the walk focuses. ONE motion in every pane kind and every buffer kind — a PDF steps the `+Search` buffer its own search filled, `n` to select the row and Enter to jump. The single thing a buffer may change is that grain, and it changes it by BEING a kind of buffer rather than by a branch: `output_pane.Traits.steps` (a list of locations) makes a step take one row at a time, and `Traits.commands` makes it take the WHOLE LINE, because a command list (`ThemeSel`/`FontSel`) holds words to run and there is no path inside `Theme gruvbox` to pick out. Tab on what `n` selected wears the theme, which is the same middle click on the row is. `]d`/`[d` are helix's diagnostic motions, a different binding, and they do still jump to each diagnostic (`docs/lsp.md`) | pardes-specific | | insert: printable text | file: real edit; terminal: typed run splice | | helix-verified (file) | | insert: `Enter` | newline; keeps the current full indent levels and adds one 4-space logical tab when the text before the cursor ends in `(`, `[`, `{`, or `)` (including `})`) | plain lines match `insert_newline`; delimiter heuristic is pardes-specific | helix-verified (plain) / pardes-specific (delimiter) | -| insert: `Backspace` (+ `Shift-Backspace`) | delete prev char, joins lines at col 0 | matches `delete_char_backward`; `Ctrl-h` alias in B | helix-verified | +| insert: `Backspace` (+ `Shift-Backspace`) | delete prev char, joins lines at col 0; in a line's leading blanks, back to the previous 4-column indent stop (a whole unit when on one; a tab still goes alone) | matches `delete_char_backward` and its dedent; `Ctrl-h` alias in B | helix-verified | | insert: `Up` `Down` `Left` `Right` | move cursor | matches helix's "not recommended" insert arrows | helix-verified | | `gd` `gD` `gy` `gi` `gr` | LSP definition / declaration / type-definition / implementation / references. ONE answer jumps straight there; several fill `+Search`, where n/N walk and Enter opens | in-process ZLS (`src/lsp/lsp_zls.zig`), `.zig` only — on a file the backend does not speak these do nothing at all, with no error row. Ctrl+left-click is the mouse spelling of `gd` | out of corpus | | `]d` `[d` / `]D` `[D` | step the diagnostics list / go to its last or first; if no list is up, asking the backend for one is part of the press | | out of corpus | @@ -397,11 +397,11 @@ Files (all in `test/hxcases/`): `sel-regex-dot-newline` (mvzr is not the Rust regex crate: no multi-line `^`/`$`, and `.` matches a newline). - `parity.jsonl` — 80 further cases, used only by the parity gate below. -- `parity-waivers.jsonl` — 13 named exemptions for the parity gate, in three +- `parity-waivers.jsonl` — 14 named exemptions for the parity gate, in three classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the tty toggle), eight pty VIEWPORT divergences (a terminal's view cannot scroll below the vt's live grid bottom, so the cursor snaps into a different - scrolloff band — no text differs), and four case texts a pty cannot hold + scrolloff band — no text differs), and five case texts a pty cannot hold verbatim (a literal TAB the emulator expands, a file with no trailing newline, an all-whitespace last row the dump trims). - `golf.jsonl` + `golf-goldens.jsonl` — every example on helix-golf diff --git a/src/edit.zig b/src/edit.zig index 2dc79ab5..5ba46ca7 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -694,8 +694,19 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { Key.backspace => { if (t.cur_col > 0) { const line = modal.lineSlice(text, c.row); - const prev = modal.prevGrapheme(line, c.col); - const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = prev }) catch return; + var prev = modal.prevGrapheme(line, c.col); + // in leading blanks, back to the previous indent stop (helix + // dedent): a whole unit when on one, else the part past it + const lead = line[0..@min(c.col, line.len)]; + if (std.mem.indexOfNone(u8, lead, " \t") == null and prev < line.len and line[prev] != '\t') { + var width: usize = 0; + for (lead) |ch| width += if (ch == '\t') modal.INDENT_W else 1; + var drop = width % modal.INDENT_W; + if (drop == 0) drop = modal.INDENT_W; + prev = c.col; + while (drop > 0 and prev > 0 and line[prev - 1] == ' ') : (drop -= 1) prev -= 1; + } + const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = prev }, c) catch return; setEditText(p, t, new); t.cur_col = @intCast(prev); t.cur_pinned = true; diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 471d3a6c..cfd82cce 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -355,6 +355,11 @@ {"name": "ins-enter-indent", "pane": "file", "text": " foo\n", "keys": "Ax"} {"name": "ins-backspace", "pane": "file", "text": "abc\n", "keys": "lli"} {"name": "ins-backspace-join", "pane": "file", "text": "ab\ncd\n", "keys": "ji"} +{"name": "ins-backspace-indent-unit", "pane": "file", "text": " x\n", "keys": "8li"} +{"name": "ins-backspace-indent-partial", "pane": "file", "text": " x\n", "keys": "6li"} +{"name": "ins-backspace-indent-tab", "pane": "file", "text": "\t\tx\n", "keys": "2li"} +{"name": "ins-backspace-open-indent", "pane": "file", "text": " ab\n", "keys": "O"} +{"name": "ins-backspace-after-text", "pane": "file", "text": " a x\n", "keys": "7li"} {"name": "ins-ctrl-w", "pane": "file", "text": "one two\n", "keys": "A"} {"name": "ins-ctrl-w-col0-join", "pane": "file", "text": "ab\ncd\n", "keys": "ji"} {"name": "ins-alt-bs", "pane": "file", "text": "one two\n", "keys": "A"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 516b2417..e258b485 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -355,6 +355,11 @@ {"name":"ins-enter-indent","text":" foo\n x\n","mode":"normal","cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}} {"name":"ins-backspace","text":"ac\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} {"name":"ins-backspace-join","text":"abcd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} +{"name":"ins-backspace-indent-unit","text":" x\n","mode":"insert","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} +{"name":"ins-backspace-indent-partial","text":" x\n","mode":"insert","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} +{"name":"ins-backspace-indent-tab","text":"\tx\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"ins-backspace-open-indent","text":"\n ab\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"ins-backspace-after-text","text":" a x\n","mode":"insert","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}} {"name":"ins-ctrl-w","text":"one \n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} {"name":"ins-ctrl-w-col0-join","text":"cd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"ins-alt-bs","text":"one \n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index 39a6a7c9..ad24eaa9 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -11,3 +11,4 @@ {"name":"comment-tab-indent","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"comment-tab-indent","text":"\t# alpha\n\t# beta\n","mode":"normal","cursor":{"row":1,"col":7},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-tab-indent","text":" # alpha\n # beta\n","mode":"normal","cursor":{"row":1,"col":14},"anchor":{"row":0,"col":0}}} {"name":"comment-noeol","reason":"Terminal capture restores a trailing newline that the source omits.","reference":{"name":"comment-noeol","text":"# alpha","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},"expected":{"name":"comment-noeol","text":"# alpha\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}} {"name":"comment-only-blank","reason":"Terminal capture expands tabs and trims trailing blank rows.","reference":{"name":"comment-only-blank","text":" \n\t\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-only-blank","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}} +{"name":"ins-backspace-indent-tab","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"ins-backspace-indent-tab","text":"\tx\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},"expected":{"name":"ins-backspace-indent-tab","text":" x\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} -- cgit v1.3