Skip to content

ADR 0139: The column a caret is aiming for

Status

Accepted

Context

Moving down through a short line and out the other side should come back to the column you started in. Every editor does this, and vim calls the remembered column curswant.

ROADMAP had it as "vertical multi-cursor movement doesn't track a sticky column per cursor (only single-cursor mode does) — a minor, rare-in-practice paper cut". That description was wrong twice over, which is the interesting part of this change.

It was not only multi-cursor. keyPressEvent() cleared the remembered column near the top, before dispatching the key. The arrow keys are handled above that line, so Up and Down kept theirs. Vim's j and k are handled below it, through handleVimNormalOrVisualKey(), so they were handed a cleared column on every keystroke. The editor's primary way of moving down a file never had a sticky column at all.

It was not rare. Any file with lines of different lengths, which is all of them.

Decision

The remembered column is per caret, and self-validating: stored alongside the offset it was true at. A caret found somewhere else has been moved by something that is not j/k, so its column is recomputed from where it actually is.

The alternative was to clear it explicitly in everything that moves a caret sideways. That is the model vim uses, and here it would have meant finding every motion, every operator and every edit that assigns into m_cursors — dozens of sites, where missing one leaves a caret mysteriously jumping columns. Validating on read cannot be forgotten, and fails safe: an unrecognised position recomputes, which is the old behaviour.

The blanket clear in keyPressEvent() is gone. The remaining clears are the ones that mean it: a click, a find jump, select-all.

Consequences

Nine conformance cases derived from real vim, and the first set of them was useless — the buffer was aaaaaaaa\nbb\ncccccccc\n, and deleting one character from a run of identical characters looks the same whichever column you deleted it in. Seven of the nine passed against code that had no sticky column whatsoever. Rewritten with every line distinct (abcdefgh\nij\nklmnopqr\n), eight of nine failed immediately.

A case that passes is only evidence if it could have failed.

A caret was allowed to stand on the newline

Two cases still failed after the column was fixed, and they were a separate bug the new cases had walked into: offsetForLineColumn() clamps a column to the line's length, which is the position after the last character. Correct in Insert mode; in Normal mode vim's caret is on a character. So j onto a short line left the caret on the line break, and x there deleted the newline and joined two lines together.

vimClampOffLineEnd() now steps it back one character — UTF-8 aware, and only outside Insert mode, and not on an empty line, where there is nowhere to step back to.

Two carets in one place are still one caret

The first multi-caret test asserted that carets crossing a very short line stay separate. They do not: they land on the same offset and normalizeCursors() merges them, as every multi-caret editor does. The sticky column cannot preserve a distinction the positions no longer carry. The test now uses a middle line that is too short for one caret but not for both, which is the case the feature is actually for.

255 conformance cases, all of them vim's own answers. Six of them and both multi-caret tests fail if the validity check is forced false.