fix dangling tail anchors in reflow on partially-filled lines (T-92)
test / unit + widget + golden + a11y (push) Failing after 29s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m0s

`_LineReflow._addPart`'s post-loop block reparents anchors past the
source line's trimmed content onto whatever `_builder._result` was
active at that moment. When no further content lands in the builder
(non-wrapped lines, or the last logical line of a wrapped run),
`finish()` was emitting only when `_builder.isNotEmpty` — leaving
the empty result line with the reparented anchor unappended. The
anchor then pointed to a `BufferLine` that the reflow output never
included, `lines.replaceWith(reflowResult)` discarded it, and
`CellAnchor.attached` returned false. The selection controller's
`extent.attached` null-check then dropped the selection silently
on resize.

The fix adds a `_LineBuilder.hasAnchors` getter and uses it in
`finish()` so the builder line is also emitted when it's carrying
an anchor — even when otherwise empty. Trade-off: an extra trailing
line in the reflow output when (and only when) a tail anchor would
have dangled. `Buffer.resize` already pads the result to `newHeight`
afterward, so for the common case (resize fits inside view height)
the total ring length is unchanged; only when the result already
meets / exceeds `newHeight` does the buffer grow by one. Acceptable
in exchange for selections surviving a width change.

User-visible trigger paths:
- `SelectAllTextIntent` (Ctrl+A) creates an end anchor at
  `x = viewWidth` on the last buffer line — exactly the past-
  trimmed-length position. Resizing narrower while the selection
  was active dropped it.
- Mouse drag selections past the end of a partially-filled line
  hit the same shape.

Tests:
- The pre-existing `reflow anchors on the source line tail (past
  trimmedLength) get reparented` test was originally written to
  document the buggy behaviour ("anchor moves off the source onto
  a dangling builder line"). Updated to assert the post-fix
  contract: `out.contains(tail.line)` is true.
- New `SelectAllTextIntent-shaped end anchor survives shrink`
  regression test that mirrors the actual production trigger
  (anchor at `x = viewWidth` on a partially-filled line, narrower
  reflow).

reflow.dart 71/71 → 72/72 (the new getter is a one-liner). Project
coverage 54.62% unchanged within rounding.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-07 07:58:44 +02:00
co-authored by Claude
parent 3196c4957f
commit 6caa82597e
4 changed files with 175 additions and 117 deletions
+22 -9
View File
@@ -354,17 +354,30 @@ void main() {
expect(out[0].getCodePoint(3), 0);
});
test('anchors on the source line tail (past trimmedLength) get reparented', () {
// The post-loop branch in `_addPart` reparents anchors whose x sits
// beyond the trimmed-content range — the empty tail of an over-wide
// source line. The anchor moves off the source onto whichever
// builder line was active at the time, regardless of whether that
// builder line ends up emitted — so the only contract we can rely
// on is that the anchor no longer points to the source.
test('anchors on the source line tail (past trimmedLength) land on a line in the result (T-92)', () {
// The post-loop tail-anchor branch in `_addPart` reparents the
// anchor onto the active builder line. The fix in T-92 makes
// `finish()` emit that builder line when it has anchors, even if
// it's otherwise empty — so the anchor's `line` is guaranteed to
// be in the reflow result and stays attached after replaceWith.
final src = line(12, 'abcdefgh'); // width 12, content 8
final tail = src.createAnchor(10); // past trimmedLength (8)
reflow(ring([src]), 12, 4);
expect(tail.line, isNot(equals(src)));
final out = reflow(ring([src]), 12, 4);
expect(tail.line, isNotNull);
expect(out.contains(tail.line), isTrue);
});
test('SelectAllTextIntent-shaped end anchor survives shrink (T-92 regression)', () {
// Models the actual bug path: SelectAllTextIntent (actions.dart)
// creates an end anchor at x = viewWidth on the last buffer line.
// Resizing narrower must not silently drop the selection.
final src = line(12, 'short');
final endAnchor = src.createAnchor(12); // viewWidth, past trimmedLength
final out = reflow(ring([src]), 12, 4);
expect(out.contains(endAnchor.line), isTrue,
reason: 'end anchor must land on a line that survives '
'lines.replaceWith — otherwise selection.attached is false '
'and the selection silently vanishes after resize.');
});
test('inner wide-char clamp during _addPart leaves the wide cell to the next iteration', () {