2 Commits
Author SHA1 Message Date
jpmschweitzerandClaude 6caa82597e 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>
2026-05-07 07:58:44 +02:00
jpmschweitzerandClaude 3196c4957f test sweep: cover core/(root) — cell, cursor, charset, tabs, reflow (T-91)
Adds test/terminal/core/core_test.dart — 36 unit tests across the
small standalone files that sit directly under
`lib/src/terminal/src/core/*.dart`:

- CellData (constructor + empty + getHash + toString),
- CursorStyle (default ctor, every set/unset attr getter pair, all
  three colour-mode setters per channel, reset, the .empty
  singleton) + CursorPosition,
- Charset (translate, designate/use, save/restore, asciiTranslator,
  decSpecGraphicsTranslator with in-table, out-of-table, and high-
  codepoint paths),
- TabStops (default 8-column grid, find with empty-range / out-of-
  bounds / no-stop-in-range cases, setAt/clearAt/clearAll/reset),
- reflow (empty input, single-line passthrough, grow, shrink-with-
  split, wrapped-run continuation, wide-char boundary on the new
  width, inner wide-char clamp during _addPart, anchor reparent on
  the main path, anchor reparent past trimmedLength).

Two source-side cleanups folded in:
- `CursorStyle.isItalis` was a defined-but-never-called getter
  with a typo. No external callers reference it; renamed to
  `isItalic` in the same change as the test that exercises it.
- `_LineBuilder.isEmpty` in reflow.dart was dead — the only callers
  use `isNotEmpty` or check `_lines.isNotEmpty` separately.
  Removed.

Coverage delta:
- cell.dart: 3/7 → 7/7.
- charset.dart: 12/25 → 25/25.
- cursor.dart: 2/62 → 62/62.
- tabs.dart: 0/23 → 23/23.
- reflow.dart: 24/72 → 71/71 (file shrank by one line after the
  isEmpty getter removal).
- Total project: 52.72% → 54.62%; coverage_floor bumped 52 → 54.

Note for follow-up (not blocking): the post-loop "anchor.x >= to"
branch in reflow's `_addPart` reparents anchors past trimmedLength
onto whatever builder line is active at that moment. If no
subsequent content is added (no wrapped continuations after the
last shrink iteration), that builder line is never emitted by
`finish()` and the anchor lands on a dangling reference. The path
is exercised by the new test, but the contract it implements is
arguably broken — anchors that should follow the source content
end up off the visible buffer. Worth a separate ticket if real
terminals trip it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 00:03:19 +02:00
6 changed files with 576 additions and 112 deletions
+130 -107
View File
@@ -1,5 +1,5 @@
{
"exported_at": "2026-05-06T21:51:46Z",
"exported_at": "2026-05-07T05:58:44Z",
"decisions": [
{
"id": "D-1",
@@ -9,7 +9,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-3",
@@ -19,7 +19,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-4",
@@ -29,7 +29,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-5",
@@ -39,7 +39,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-6",
@@ -49,7 +49,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-7",
@@ -59,7 +59,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-8",
@@ -69,7 +69,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-9",
@@ -79,7 +79,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-10",
@@ -89,7 +89,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-11",
@@ -99,7 +99,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-12",
@@ -109,7 +109,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-13",
@@ -119,7 +119,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-14",
@@ -129,7 +129,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-15",
@@ -139,7 +139,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-16",
@@ -149,7 +149,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-17",
@@ -159,7 +159,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-18",
@@ -169,7 +169,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-19",
@@ -179,7 +179,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-20",
@@ -189,7 +189,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/accessibility.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-21",
@@ -199,7 +199,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/accessibility.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-22",
@@ -209,7 +209,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/accessibility.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-23",
@@ -219,7 +219,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-24",
@@ -229,7 +229,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-25",
@@ -239,7 +239,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-26",
@@ -249,7 +249,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-27",
@@ -259,7 +259,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-28",
@@ -269,7 +269,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-29",
@@ -279,7 +279,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-30",
@@ -289,7 +289,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-31",
@@ -299,7 +299,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-32",
@@ -309,7 +309,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-33",
@@ -319,7 +319,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-34",
@@ -329,7 +329,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-35",
@@ -339,7 +339,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-36",
@@ -349,7 +349,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-37",
@@ -359,7 +359,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-38",
@@ -369,7 +369,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-39",
@@ -379,7 +379,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-40",
@@ -389,7 +389,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-41",
@@ -399,7 +399,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-42",
@@ -409,7 +409,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-43",
@@ -419,7 +419,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-44",
@@ -429,7 +429,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-45",
@@ -439,7 +439,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-46",
@@ -449,7 +449,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-47",
@@ -459,7 +459,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-48",
@@ -469,7 +469,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-49",
@@ -479,7 +479,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-50",
@@ -489,7 +489,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-51",
@@ -499,7 +499,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-52",
@@ -509,7 +509,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-53",
@@ -519,7 +519,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-54",
@@ -529,7 +529,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-55",
@@ -539,7 +539,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-56",
@@ -549,7 +549,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-57",
@@ -559,7 +559,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-58",
@@ -569,7 +569,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-59",
@@ -579,7 +579,7 @@
"status": "active",
"date": "2026-04-25",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-60",
@@ -589,7 +589,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-61",
@@ -599,7 +599,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-62",
@@ -609,7 +609,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-63",
@@ -619,7 +619,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-64",
@@ -629,7 +629,7 @@
"status": "active",
"date": "2026-05-03",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-65",
@@ -639,7 +639,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "D-66",
@@ -649,7 +649,7 @@
"status": "active",
"date": "2026-05-06",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-1",
@@ -658,7 +658,7 @@
"title": "Authorisation granularity on the IPC socket",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-2",
@@ -667,7 +667,7 @@
"title": "Back-pressure on event streams",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-3",
@@ -676,7 +676,7 @@
"title": "Event persistence + audit/undo",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-4",
@@ -685,7 +685,7 @@
"title": "`.canvas` schema compatibility with Obsidian",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-5",
@@ -694,7 +694,7 @@
"title": "IPC wire-format stability + `schema_version:`",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-6",
@@ -703,7 +703,7 @@
"title": "Window chrome — native frame vs frameless custom",
"status": "resolved",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-7",
@@ -712,7 +712,7 @@
"title": "macOS app bundle signing / notarisation",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-8",
@@ -721,7 +721,7 @@
"title": "Extension API shape — widgets, subcommands, both?",
"status": "open",
"file_path": "decisions/questions-extensions.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-9",
@@ -730,7 +730,7 @@
"title": "Lua runtime vendoring",
"status": "open",
"file_path": "decisions/questions-extensions.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-10",
@@ -739,7 +739,7 @@
"title": "Extension manifest `schema_version:`",
"status": "open",
"file_path": "decisions/questions-extensions.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-11",
@@ -748,7 +748,7 @@
"title": "Coverage gates — hard thresholds vs soft reporting",
"status": "open",
"file_path": "decisions/questions-testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-12",
@@ -757,7 +757,7 @@
"title": "Screen-reader automation (axe-core via Playwright)",
"status": "open",
"file_path": "decisions/questions-testing.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-13",
@@ -766,7 +766,7 @@
"title": "Web production-mode a11y",
"status": "open",
"file_path": "decisions/questions-accessibility.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-14",
@@ -775,7 +775,7 @@
"title": "i18n plurals / gender / date-format tooling",
"status": "open",
"file_path": "decisions/questions-accessibility.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-15",
@@ -784,7 +784,7 @@
"title": "Editor tab — full LSP vs tree-sitter-only highlight",
"status": "open",
"file_path": "decisions/questions-process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-16",
@@ -793,7 +793,7 @@
"title": "`tree-sitter-dart` grammar maintenance",
"status": "open",
"file_path": "decisions/questions-process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-17",
@@ -802,7 +802,7 @@
"title": "Icon set growth",
"status": "open",
"file_path": "decisions/questions-process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-18",
@@ -811,7 +811,7 @@
"title": "Theme hot-reload in release builds",
"status": "open",
"file_path": "decisions/questions-process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-19",
@@ -820,7 +820,7 @@
"title": "(withdrawn)",
"status": "resolved",
"file_path": "decisions/questions-process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-20",
@@ -829,7 +829,7 @@
"title": "Kernel DB service — namespaced SQL access?",
"status": "open",
"file_path": "decisions/questions-process.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-21",
@@ -838,7 +838,7 @@
"title": "Pql absorbs planning vs keeps separate",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-22",
@@ -847,7 +847,7 @@
"title": "Ticket persistence strategy",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-23",
@@ -856,7 +856,7 @@
"title": "SSH-remote development — run clide against a remote workspace",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-25",
@@ -865,7 +865,7 @@
"title": "Body text face — mono everywhere vs Josefin Sans UI + mono code",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-26",
@@ -874,7 +874,7 @@
"title": "Small screen layout (\u003c 1000px)",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-27",
@@ -883,7 +883,7 @@
"title": "Two-editor split",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-28",
@@ -892,7 +892,7 @@
"title": "Terminal strip scope — shell only or logs/errors/tests",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-29",
@@ -901,7 +901,7 @@
"title": "Branch picker location",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-30",
@@ -910,7 +910,7 @@
"title": "Focus behavior when editor is dirty and viewer is peeked",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "Q-31",
@@ -919,7 +919,7 @@
"title": "XWayland fallback for frameless — proper Wayland protocol needed",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-2",
@@ -929,7 +929,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-3",
@@ -939,7 +939,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-4",
@@ -949,7 +949,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-5",
@@ -959,7 +959,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-6",
@@ -969,7 +969,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-7",
@@ -979,7 +979,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-8",
@@ -989,7 +989,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-9",
@@ -999,7 +999,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-10",
@@ -1009,7 +1009,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-11",
@@ -1019,7 +1019,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
},
{
"id": "R-12",
@@ -1029,7 +1029,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:51:08"
"synced_at": "2026-05-07 05:57:38"
}
],
"decision_refs": [
@@ -2791,6 +2791,16 @@
"decision_ref": "D-66",
"created_at": "2026-05-06 20:49:47",
"updated_at": "2026-05-06 20:50:32"
},
{
"id": "T-92",
"type": "bug",
"title": "reflow tail-anchor reparenting can dangle on partially-filled lines",
"description": "**File:** `lib/src/terminal/src/core/reflow.dart` (`_LineReflow._addPart`).\n\n**Bug:** Anchors whose x-coordinate sits past the source line's `trimmedLength` get reparented onto whatever `_builder._result` is active at that moment. After the main reparenting loop has run with `lineFilled` on the final iteration, `_builder.take()` has already moved the filled line into `_lines` and reset `_result` to a fresh empty `BufferLine`. The post-loop block (lines 144-150 in the current source) then reparents the tail anchor onto that fresh empty line. If no further content lands in the builder before `finish()` exits — which is the case whenever the post-loop block fires on a non-wrapped logical line — the empty builder line is never appended to `_lines`, and the anchor ends up pointing to a `BufferLine` that the reflow result never includes.\n\n**Trigger paths in production code:**\n\n1. `SelectAllTextIntent` (`lib/src/terminal/src/ui/shortcut/actions.dart:60-63`) creates the end anchor at `x = terminal.viewWidth`. That is *exactly* the past-trimmedLength position for every partially-filled line in the buffer. Any user who Ctrl+A's and then resizes the terminal narrower hits the bug for every short line in the scrollback.\n2. `selectCharacters` / mouse drag selection (`lib/src/terminal/src/ui/render.dart:286-298`) — anchors are clamped to `[0, viewWidth-1]` but can sit past `trimmedLength` on a partially-filled line. Drag past content end + resize narrower trips the bug.\n3. `selectWord` (`render.dart:264-275`) — `getWordBoundary` walks until a separator OR `viewWidth`, so the end anchor of a near-edge word can land at `x = viewWidth` and trip the bug on resize.\n\n**Symptom:** selection extents lose track of content after a width change. Visible highlight stops moving with the underlying selection, or extents disappear off the rendered buffer entirely. No exception is thrown — the dangling anchor still has a valid `BufferLine` reference, it just isn't on a line in the visible buffer.\n\n**Acceptance:**\n- New unit test in `test/terminal/core/core_test.dart` (or a sibling) that:\n - Constructs a partially-filled source line and an anchor past `trimmedLength`,\n - Runs reflow shrink,\n - Asserts the anchor's owning line is *in the reflow output list*, not dangling.\n- The existing widget-level select-all behaviour still works on resize: a Ctrl+A across the buffer followed by a narrower resize keeps the selection's end anchor pointing to a line in the reflowed result.\n- No regression in the existing reflow tests (the tail-clamp cases, wide-char boundaries, multi-line wrapped runs).\n\n**Investigation notes (do not skip):**\n\nThe fix is conceptually small — the reparented tail anchor must land on a line that gets emitted. Two candidate shapes:\n\n- **Skip the reparent entirely.** Anchors past `trimmedLength` arguably shouldn't survive a shrink that drops their column. Risk: callers expect every anchor on the source to be reparented somewhere; silently disposing them changes semantics.\n- **Force `finish()` to flush the builder line.** Whenever the tail-anchor branch fired during `_addPart`, set a flag, and have `finish()` emit the builder line even if `isEmpty`. Risk: empty trailing lines start showing up in the reflow result, which would break callers that assume reflow returns only content lines (resize loop in `Buffer.resize` pads to viewport height after, so an extra empty might be benign there but worth checking).\n\nDon't pick one without re-reading the full reflow flow + every caller of `reflow()`. The selection round-trip is also tested at the widget layer indirectly — verify `make test-a11y` still passes after the change.\n\n**Surfaced from:** T-91 test sweep (commit 3196c49). The 100% coverage on reflow.dart was achieved by exercising this branch but explicitly *not* asserting on the resulting line being in the output — see the test comment.\n\n**Cross-references:** T-91 (epic parent for the test sweep that found this), T-89 (coverage epic), the test in `test/terminal/core/core_test.dart` group `reflow` named \"anchors on the source line tail (past trimmedLength) get reparented\" documents the current (buggy) behaviour.\n",
"status": "in_progress",
"priority": "high",
"created_at": "2026-05-07 05:51:48",
"updated_at": "2026-05-07 05:56:47"
}
],
"ticket_deps": null,
@@ -4429,6 +4439,19 @@
"old_value": "backlog",
"new_value": "in_progress",
"changed_at": "2026-05-06 20:50:32"
},
{
"ticket_id": "T-92",
"field": "description",
"new_value": "**File:** `lib/src/terminal/src/core/reflow.dart` (`_LineReflow._addPart`).\n\n**Bug:** Anchors whose x-coordinate sits past the source line's `trimmedLength` get reparented onto whatever `_builder._result` is active at that moment. After the main reparenting loop has run with `lineFilled` on the final iteration, `_builder.take()` has already moved the filled line into `_lines` and reset `_result` to a fresh empty `BufferLine`. The post-loop block (lines 144-150 in the current source) then reparents the tail anchor onto that fresh empty line. If no further content lands in the builder before `finish()` exits — which is the case whenever the post-loop block fires on a non-wrapped logical line — the empty builder line is never appended to `_lines`, and the anchor ends up pointing to a `BufferLine` that the reflow result never includes.\n\n**Trigger paths in production code:**\n\n1. `SelectAllTextIntent` (`lib/src/terminal/src/ui/shortcut/actions.dart:60-63`) creates the end anchor at `x = terminal.viewWidth`. That is *exactly* the past-trimmedLength position for every partially-filled line in the buffer. Any user who Ctrl+A's and then resizes the terminal narrower hits the bug for every short line in the scrollback.\n2. `selectCharacters` / mouse drag selection (`lib/src/terminal/src/ui/render.dart:286-298`) — anchors are clamped to `[0, viewWidth-1]` but can sit past `trimmedLength` on a partially-filled line. Drag past content end + resize narrower trips the bug.\n3. `selectWord` (`render.dart:264-275`) — `getWordBoundary` walks until a separator OR `viewWidth`, so the end anchor of a near-edge word can land at `x = viewWidth` and trip the bug on resize.\n\n**Symptom:** selection extents lose track of content after a width change. Visible highlight stops moving with the underlying selection, or extents disappear off the rendered buffer entirely. No exception is thrown — the dangling anchor still has a valid `BufferLine` reference, it just isn't on a line in the visible buffer.\n\n**Acceptance:**\n- New unit test in `test/terminal/core/core_test.dart` (or a sibling) that:\n - Constructs a partially-filled source line and an anchor past `trimmedLength`,\n - Runs reflow shrink,\n - Asserts the anchor's owning line is *in the reflow output list*, not dangling.\n- The existing widget-level select-all behaviour still works on resize: a Ctrl+A across the buffer followed by a narrower resize keeps the selection's end anchor pointing to a line in the reflowed result.\n- No regression in the existing reflow tests (the tail-clamp cases, wide-char boundaries, multi-line wrapped runs).\n\n**Investigation notes (do not skip):**\n\nThe fix is conceptually small — the reparented tail anchor must land on a line that gets emitted. Two candidate shapes:\n\n- **Skip the reparent entirely.** Anchors past `trimmedLength` arguably shouldn't survive a shrink that drops their column. Risk: callers expect every anchor on the source to be reparented somewhere; silently disposing them changes semantics.\n- **Force `finish()` to flush the builder line.** Whenever the tail-anchor branch fired during `_addPart`, set a flag, and have `finish()` emit the builder line even if `isEmpty`. Risk: empty trailing lines start showing up in the reflow result, which would break callers that assume reflow returns only content lines (resize loop in `Buffer.resize` pads to viewport height after, so an extra empty might be benign there but worth checking).\n\nDon't pick one without re-reading the full reflow flow + every caller of `reflow()`. The selection round-trip is also tested at the widget layer indirectly — verify `make test-a11y` still passes after the change.\n\n**Surfaced from:** T-91 test sweep (commit 3196c49). The 100% coverage on reflow.dart was achieved by exercising this branch but explicitly *not* asserting on the resulting line being in the output — see the test comment.\n\n**Cross-references:** T-91 (epic parent for the test sweep that found this), T-89 (coverage epic), the test in `test/terminal/core/core_test.dart` group `reflow` named \"anchors on the source line tail (past trimmedLength) get reparented\" documents the current (buggy) behaviour.\n",
"changed_at": "2026-05-07 05:52:26"
},
{
"ticket_id": "T-92",
"field": "status",
"old_value": "backlog",
"new_value": "in_progress",
"changed_at": "2026-05-07 05:56:47"
}
]
}
+9
View File
@@ -47,6 +47,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed
- Terminal selections no longer silently disappear when the terminal is
resized narrower. Reflow's tail-anchor handler used to reparent
anchors past the source's trimmed-content range onto a builder line
that was never emitted, leaving them detached from the visible
buffer; the selection controller's `extent.attached` check then
returned null and the highlight vanished. Common trigger paths:
Ctrl+A (select-all) followed by a width change, and mouse drag
selections that extended past the end of a partially-filled line
(T-92).
- `BufferLine.removeCells` / `insertCells` / `dispose` no longer skip
anchors due to concurrent list modification during iteration —
surfaced by unit tests added under T-91. Anchors disposed inside
+1 -1
View File
@@ -81,7 +81,7 @@ class CursorStyle {
bool get isFaint => (attrs & CellAttr.faint) != 0;
bool get isItalis => (attrs & CellAttr.italic) != 0;
bool get isItalic => (attrs & CellAttr.italic) != 0;
bool get isUnderline => (attrs & CellAttr.underline) != 0;
+14 -3
View File
@@ -16,10 +16,17 @@ class _LineBuilder {
int get length => _length;
bool get isEmpty => _length == 0;
bool get isNotEmpty => _length != 0;
/// True when the result line carries at least one CellAnchor. Used by
/// [_LineReflow.finish] to flush an otherwise-empty trailing line when
/// the tail-anchor branch in `_addPart` reparented an anchor onto it
/// — without this guard the anchor would point to a `BufferLine` that
/// reflow never emits, the selection controller's `extent.attached`
/// check would fail, and the selection would silently vanish on resize
/// (T-92).
bool get hasAnchors => _result.anchors.isNotEmpty;
/// Adds a range of cells from [src] to the builder. Anchors within the range
/// will be reparented to the new line returned by [take].
void add(BufferLine src, int start, int length) {
@@ -154,7 +161,11 @@ class _LineReflow {
/// Finalizes the reflow operation and returns the result.
List<BufferLine> finish() {
if (_builder.isNotEmpty) {
// Emit the trailing builder line if it has content OR if it's
// carrying an anchor. The latter keeps tail-anchored selections
// (e.g. SelectAllTextIntent's end anchor at x=viewWidth) attached
// to a line that's actually in the reflow output (T-92).
if (_builder.isNotEmpty || _builder.hasAnchors) {
_lines.add(_builder.take(wrapped: _lines.isNotEmpty));
}
+1 -1
View File
@@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide
# Pre-push line-coverage floor. Ratchets up only — see D-66.
# Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`.
coverage_floor: 52
coverage_floor: 54
# Project metadata (was project.yaml, folded in per D-056).
# version: above is the single source of truth. The Makefile reads
+421
View File
@@ -0,0 +1,421 @@
/// Pure-Dart tests for the small `lib/src/terminal/src/core/*.dart`
/// files — cell, cursor, charset, tabs, reflow.
library;
import 'package:clide/src/terminal/src/core/buffer/line.dart';
import 'package:clide/src/terminal/src/core/cell.dart';
import 'package:clide/src/terminal/src/core/charset.dart';
import 'package:clide/src/terminal/src/core/cursor.dart';
import 'package:clide/src/terminal/src/core/reflow.dart';
import 'package:clide/src/terminal/src/core/tabs.dart';
import 'package:clide/src/terminal/src/utils/circular_buffer.dart';
import 'package:test/test.dart';
void main() {
group('CellData', () {
test('explicit constructor stores all four channels', () {
final c = CellData(foreground: 1, background: 2, flags: 4, content: 8);
expect(c.foreground, 1);
expect(c.background, 2);
expect(c.flags, 4);
expect(c.content, 8);
});
test('CellData.empty factory zeroes everything', () {
final c = CellData.empty();
expect(c.foreground, 0);
expect(c.background, 0);
expect(c.flags, 0);
expect(c.content, 0);
});
test('getHash combines all four channels (different inputs → different hash)', () {
final a = CellData(foreground: 1, background: 2, flags: 3, content: 4);
final b = CellData(foreground: 1, background: 2, flags: 3, content: 4);
expect(a.getHash(), b.getHash());
final c = CellData(foreground: 1, background: 2, flags: 3, content: 5);
expect(a.getHash(), isNot(c.getHash()));
});
test('toString shape is the documented debug format', () {
final c = CellData(foreground: 1, background: 2, flags: 4, content: 8);
expect(
c.toString(),
'CellData{foreground: 1, background: 2, flags: 4, content: 8}',
);
});
});
group('CursorStyle', () {
test('default constructor zeroes all three fields', () {
final c = CursorStyle();
expect(c.foreground, 0);
expect(c.background, 0);
expect(c.attrs, 0);
});
test('every set / unset attr toggles the right CellAttr bit', () {
final c = CursorStyle();
// Pair each setter with its corresponding getter and unsetter.
final cases = <(void Function(), void Function(), bool Function())>[
(c.setBold, c.unsetBold, () => c.isBold),
(c.setFaint, c.unsetFaint, () => c.isFaint),
(c.setItalic, c.unsetItalic, () => c.isItalic),
(c.setUnderline, c.unsetUnderline, () => c.isUnderline),
(c.setBlink, c.unsetBlink, () => c.isBlink),
(c.setInverse, c.unsetInverse, () => c.isInverse),
(c.setInvisible, c.unsetInvisible, () => c.isInvisible),
];
for (final (set, unset, getter) in cases) {
c.attrs = 0;
expect(getter(), isFalse);
set();
expect(getter(), isTrue);
unset();
expect(getter(), isFalse);
}
// Strikethrough has no getter — exercise its set / unset directly.
c.attrs = 0;
c.setStrikethrough();
expect(c.attrs & CellAttr.strikethrough, isNot(0));
c.unsetStrikethrough();
expect(c.attrs & CellAttr.strikethrough, 0);
});
test('foreground 16 / 256 / RGB encode the right CellColor type bits', () {
final c = CursorStyle();
c.setForegroundColor16(3);
expect(c.foreground, 3 | CellColor.named);
c.setForegroundColor256(200);
expect(c.foreground, 200 | CellColor.palette);
c.setForegroundColorRgb(0x10, 0x20, 0x30);
expect(c.foreground, 0x10 << 16 | 0x20 << 8 | 0x30 | CellColor.rgb);
c.resetForegroundColor();
expect(c.foreground, 0);
});
test('background 16 / 256 / RGB encode the right CellColor type bits', () {
final c = CursorStyle();
c.setBackgroundColor16(5);
expect(c.background, 5 | CellColor.named);
c.setBackgroundColor256(123);
expect(c.background, 123 | CellColor.palette);
c.setBackgroundColorRgb(1, 2, 3);
expect(c.background, 1 << 16 | 2 << 8 | 3 | CellColor.rgb);
c.resetBackgroundColor();
expect(c.background, 0);
});
test('reset wipes all three fields', () {
final c = CursorStyle()
..setBold()
..setForegroundColor16(2)
..setBackgroundColor256(99);
c.reset();
expect(c.foreground, 0);
expect(c.background, 0);
expect(c.attrs, 0);
});
test('CursorStyle.empty exposes a default-zero singleton', () {
// Don't mutate this — it's a shared singleton.
expect(CursorStyle.empty.foreground, 0);
expect(CursorStyle.empty.background, 0);
expect(CursorStyle.empty.attrs, 0);
});
});
group('CursorPosition', () {
test('exposes mutable x / y fields', () {
final p = CursorPosition(3, 5);
expect(p.x, 3);
expect(p.y, 5);
p.x = 7;
p.y = 9;
expect(p.x, 7);
expect(p.y, 9);
});
});
group('Charset', () {
test('asciiTranslator is identity', () {
expect(asciiTranslator(0x41), 0x41);
expect(asciiTranslator(0x7e), 0x7e);
});
test('decSpecGraphicsTranslator maps the documented codepoints', () {
// 0x6a → BOX DRAWINGS LIGHT UP AND LEFT (0x2518)
expect(decSpecGraphicsTranslator(0x6a), 0x2518);
// 0x71 → BOX DRAWINGS LIGHT HORIZONTAL (0x2500)
expect(decSpecGraphicsTranslator(0x71), 0x2500);
});
test('decSpecGraphicsTranslator passes unmapped low-codepoints through', () {
expect(decSpecGraphicsTranslator(0x41), 0x41); // 'A' is unmapped
});
test('decSpecGraphicsTranslator passes high codepoints through', () {
// The spec only maps 0x5f..0x7e; anything ≥ 127 is identity.
expect(decSpecGraphicsTranslator(0x100), 0x100);
expect(decSpecGraphicsTranslator(0x4E2D), 0x4E2D);
});
test('translate defaults to ascii when no charset is designated', () {
final c = Charset();
expect(c.translate(0x6a), 0x6a);
});
test('designate + use switch the active translator', () {
final c = Charset();
// Designate the DEC special-graphics charset at slot 0…
c.designate(0, '0'.codeUnitAt(0));
// …and select slot 0 as the active charset.
c.use(0);
expect(c.translate(0x6a), 0x2518); // now maps via DEC graphics
// Switch back to a slot with no designated charset → ascii fallback.
c.use(1);
expect(c.translate(0x6a), 0x6a);
});
test('designate ignores unknown charset names without changing state', () {
final c = Charset();
c.designate(0, 0xFFFF); // not in the _charsets table
c.use(0);
expect(c.translate(0x6a), 0x6a); // still ascii
});
test('save / restore round-trips both the map and the active index', () {
final c = Charset();
c.designate(0, '0'.codeUnitAt(0));
c.use(0);
c.save();
// Mutate after save…
c.designate(0, 'B'.codeUnitAt(0)); // ascii in slot 0 — translate is identity
c.use(1);
expect(c.translate(0x6a), 0x6a);
// …restore should reinstate the saved DEC-graphics binding.
c.restore();
expect(c.translate(0x6a), 0x2518);
});
});
group('TabStops', () {
test('default state has tab stops every 8 columns', () {
final t = TabStops();
for (var i = 0; i < 100; i++) {
expect(t.isSetAt(i), i % 8 == 0, reason: 'col $i');
}
});
test('find — returns first set stop in [start, end)', () {
final t = TabStops();
// Default: 0, 8, 16, 24, ...
expect(t.find(1, 20), 8);
expect(t.find(8, 20), 8);
expect(t.find(9, 16), isNull); // no stops in [9, 16)
expect(t.find(0, 1), 0);
});
test('find returns null when start >= end', () {
final t = TabStops();
expect(t.find(10, 10), isNull);
expect(t.find(20, 5), isNull);
});
test('find clamps end to the underlying array length', () {
final t = TabStops();
// 99999 > _kMaxColumns; should still return a valid stop without
// walking out of bounds.
expect(t.find(0, 99999), 0);
});
test('setAt + clearAt toggle individual columns', () {
final t = TabStops();
t.clearAt(8);
expect(t.isSetAt(8), isFalse);
t.setAt(3);
expect(t.isSetAt(3), isTrue);
t.clearAt(3);
expect(t.isSetAt(3), isFalse);
});
test('clearAll wipes every stop without restoring the default grid', () {
final t = TabStops();
t.clearAll();
for (var i = 0; i < 100; i++) {
expect(t.isSetAt(i), isFalse, reason: 'col $i');
}
});
test('reset clears then re-installs the default 8-column grid', () {
final t = TabStops();
t.setAt(3);
t.clearAt(8);
t.reset();
// 3 should no longer be set; 8 should be set again.
expect(t.isSetAt(3), isFalse);
expect(t.isSetAt(8), isTrue);
});
});
group('reflow', () {
BufferLine line(int width, [String? text]) {
final l = BufferLine(width);
if (text != null) {
for (var i = 0; i < text.length; i++) {
l.setCodePoint(i, text.codeUnitAt(i));
}
}
return l;
}
IndexAwareCircularBuffer<BufferLine> ring(List<BufferLine> ls) {
final r = IndexAwareCircularBuffer<BufferLine>(ls.length + 4);
for (final l in ls) {
r.push(l);
}
return r;
}
test('empty input produces empty output', () {
final out = reflow(ring([]), 80, 80);
expect(out, isEmpty);
});
test('single empty line is reused as-is', () {
final l = line(8);
final out = reflow(ring([l]), 8, 8);
expect(out.length, 1);
expect(identical(out.first, l), isTrue);
});
test('grow width keeps lines and resizes them to the new width', () {
final out = reflow(ring([line(4, 'abcd')]), 4, 8);
expect(out, hasLength(1));
expect(out.first.length, 8);
expect(out.first.getCodePoint(0), 'a'.codeUnitAt(0));
expect(out.first.getCodePoint(3), 'd'.codeUnitAt(0));
});
test('shrink width splits a single full line into wrapped pieces', () {
// 8 chars on a width-8 line, reflowed to width 4 → two width-4 lines.
final out = reflow(ring([line(8, 'abcdefgh')]), 8, 4);
expect(out, hasLength(2));
expect(out[0].getCodePoint(0), 'a'.codeUnitAt(0));
expect(out[0].isWrapped, isFalse); // first line of a logical run
expect(out[1].getCodePoint(0), 'e'.codeUnitAt(0));
expect(out[1].isWrapped, isTrue);
});
test('continues a wrapped run across input lines and re-emits wrapped output', () {
// Two width-4 lines forming a single logical line "abcdefgh", reflowed
// back to width 8 → a single line of 8 chars.
final a = line(4, 'abcd');
final b = line(4, 'efgh')..isWrapped = true;
final out = reflow(ring([a, b]), 4, 8);
expect(out, hasLength(1));
expect(out.first.getCodePoint(0), 'a'.codeUnitAt(0));
expect(out.first.getCodePoint(7), 'h'.codeUnitAt(0));
});
test('shrink across a wide char does not split it across two output lines', () {
// Layout on a width-6 input: 'ab中cd' — the wide char straddles
// columns 2..3. Reflow to width 4 → first line is 'ab' + space (the
// wide char wouldn't fit), second line carries the wide char + 'cd'.
final l = BufferLine(6);
l.setCodePoint(0, 'a'.codeUnitAt(0));
l.setCodePoint(1, 'b'.codeUnitAt(0));
l.setCodePoint(2, '中'.runes.first); // width 2 → occupies 2..3
l.setCodePoint(4, 'c'.codeUnitAt(0));
l.setCodePoint(5, 'd'.codeUnitAt(0));
final out = reflow(ring([l]), 6, 4);
expect(out.length, greaterThanOrEqualTo(2));
// First output line should not have started its last cell on the wide
// char's first half — i.e. cell index 3 should be empty (width-clamp
// branch fired during the initial fill).
expect(out[0].getCodePoint(3), 0);
});
test('shrink with a wide char at the new boundary takes the from=newWidth-1 path', () {
// The branch at reflow.dart:88 only fires when the cell at the new
// boundary (newWidth-1) is itself the first half of a wide char.
final l = BufferLine(6);
l.setCodePoint(0, 'a'.codeUnitAt(0));
l.setCodePoint(1, 'b'.codeUnitAt(0));
l.setCodePoint(2, 'c'.codeUnitAt(0));
l.setCodePoint(3, '中'.runes.first); // wide → straddles 3..4
l.setCodePoint(5, 'd'.codeUnitAt(0));
// newWidth=4 → boundary cell is 3, which is the wide char's first half.
final out = reflow(ring([l]), 6, 4);
expect(out.length, greaterThanOrEqualTo(2));
// The wide char should not survive as a half-cell on the first line.
expect(out[0].getCodePoint(3), 0);
});
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)
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', () {
// The clamp at reflow.dart:124 fires when the iteration is about to
// fill the builder AND the last cell would be the first half of a
// wide char — _addPart pushes that copy to the next iteration.
final src = BufferLine(9);
for (var i = 0; i < 7; i++) {
src.setCodePoint(i, 'a'.codeUnitAt(0) + i);
}
// Set wide at index 7 (straddles 7..8).
src.setCodePoint(7, '中'.runes.first);
final out = reflow(ring([src]), 9, 4);
expect(out.length, greaterThanOrEqualTo(2));
});
test('anchors on the source line get reparented to the reflowed output', () {
// CellAnchor.attached delegates to its owning BufferLine.attached
// (which itself reflects whether the line sits in an
// IndexAwareCircularBuffer). Reflow returns a plain List, so the
// output lines aren't "attached" — but anchors should still point
// to one of the lines in the result.
final src = line(8, 'abcdefgh');
final anchorAtStart = src.createAnchor(0);
final anchorMid = src.createAnchor(5);
final out = reflow(ring([src]), 8, 4);
expect(out, hasLength(2));
// anchorAtStart was at x=0 on the original line; reflow's first
// step is `_lines.add(line)`, so the original line is the first
// output. The anchor should still point to that same instance.
expect(out.contains(anchorAtStart.line), isTrue);
// anchorMid was at x=5; the [4..8) tail is split out via _addPart
// and lands on the second output line, with the anchor reparented
// there.
expect(out.contains(anchorMid.line), isTrue);
expect(anchorMid.line, isNot(equals(anchorAtStart.line)));
});
});
}