301. A selection is geometry the frame already had
Date: 2026-09-13
Status
Accepted. Makes both content views selectable — drag, double-click, triple-click,
Ctrl+A, Ctrl+C — and closes the last item on docs/gaps.md G17 that was not
an engine’s.
Completes ADR-0300,
whose “Text selection is not here, and this is what it needs” section is the
design this carries out. Depends on
ADR-0119’s Located and
ADR-0299’s
paragraph cache, and it could not have been built cheaply without either.
Context
Both views rendered documents a reader could not take a copy of, and ADR-0300 listed three obstacles rather than building it:
- Hit-testing a point to a word and an offset.
buildandrenderrun before Yoga, so a word has no idea where it is (ADR-0080), and a widget can see its own size but not its children’s rectangles. - Painting the highlight without a rebuild. A
selectedclass per word means a rebuild of six hundred widgets per pointer move — the frame shape ADR-0299 had just removed. - Character granularity. A selection that snapped to whole words is not a selection anybody uses twice.
Each of those had an answer already in the toolkit; what was missing was the observation that they fit together.
Decision
Every word says where it landed
Located — the third geometry facility, and the only one that carries a
position — tells a widget where the last frame painted it and what clips it,
in window coordinates, once a frame and only when it changes. That is exactly
what a selection needs, and scrolled is the position it reports, which is what
“where the reader sees the words” means.
So a document’s words are no longer text widgets from the catalog but
content.select.Word:
- it draws what
textdraws, through the same measured-leaf box and the same paragraph cache — one widget per word either way, which is the whole reason this is affordable after ADR-0299; - it is
Located, so it reports its rectangle into aWordGeometry; - it hands its shaped
Paragraphto that geometry, which is what turns an x into a character offset —Paragraph.offsetAt, the same arithmetic a caret in atext-inputuses.
A wrapper node per word was the obvious alternative and is the one that costs: twice the elements for a document, which is precisely the cost the previous record paid down.
A drag repaints; it does not rebuild
The selection is mutable state — two carets — that the pointer handler writes
and an overlay’s painter reads at paint time. A pointer move therefore costs one
Host.repaint(): no build, no cascade, no layout. That is the decision the rest
of the design hangs off, and it is why the highlight is a painter rather than a
class.
The overlay is selection-layer, absolutely positioned and inset to nothing, so
it fills the document’s padding box and contributes no layout. It is the first
child, because paint order is document order: the wash goes behind the words
rather than over them. It is Located too, which is how window rectangles become
its own coordinates with no assumption about padding or borders.
Its colour is var(--gb-selection) from the cascade, so a theme decides what a
selection looks like.
What a reader can do
Drag to select; double-click for a word; triple-click for a block — a paragraph,
a heading, a cell, a line of a fence; Ctrl+A for the document; Escape to let
it go; Ctrl+C to copy. Links and images are part of a selection: their
rectangles are washed and a link’s label is in what gets copied, because a
selection that skipped them would copy “Read first.” out of “Read the help
first.”
The copied text carries the separators the document implies — a space between
words, a newline between blocks — and that information is the fold’s, because a
wrapping row draws its spaces as gaps between boxes rather than as characters.
Each word therefore carries the separator that belongs in front of it, and
WordMinter is where the fold says so. Without it a copy pastes
Thequickbrownfox.
Two things it deliberately is not: an editor — no caret, nothing blinks, nothing can be typed — and a drag that starts on a link or a task box, because the router captures on press and that press belongs to the control.
A selection is dropped when the document changes under it
A preview re-parses on every keystroke. The geometry notices when a build registers different words from the last one and the selection is cleared, because keeping it would highlight whatever is now at those indices. A rebuild that changes nothing keeps it, which is the half that makes the rule worth having: a frame that re-parsed the same text must not take a reader’s selection away.
Consequences
The last non-engine item on G17 is closed. What litehtml would still buy is real inline layout — a line of mixed faces as one shaped run, and with it justification and hyphenation. Selection is no longer on that list.
A document’s words are a word CSS type rather than text. No stylesheet in
the repository styled a bare text type, and both content sheets style the
md-word / html-word classes, which are unchanged — so nothing moved visually,
which the goldens say. An application that styled text inside a document
would have to say word now.
Every word is Located, so the router notifies six hundred nodes a frame. It
is a map write and a small record each, measured at no change to the document’s
style (0.64 ms) or layout (0.82 ms) pass. Worth knowing it is there: the notify
walk is now proportional to the words on screen rather than to the handful of
widgets that used to ask.
The view is stateful now, through one SelectableDocument node between the
view and the fold’s column. MarkdownView and HtmlView stay the same records
they were — the state is in a node they build, which is also what lets both
halves share every line of this.
A selection-host node wraps every rendered document. It hears the pointer
and the keyboard, is focusable — Ctrl+C goes to whatever has the focus — and
carries cursor: text. It draws nothing. One consequence is visible: a
background on .markdown stops where the words do rather than filling the pane,
because the pane’s child is now the host. The golden tests frame their documents
on the host for that reason.
Auto-scroll while dragging past the edge is not implemented. Dragging to the
bottom of a viewport stops selecting rather than scrolling on; the design for it
is the same one a text-area wants and neither has it yet.
Alternatives considered
A selected class on each covered word. The obvious implementation, rejected
on arithmetic: a class is part of a widget’s description, so every pointer move
would rebuild the document and re-resolve every style — 600 elements at 60 Hz, to
change a colour. The painter reads mutable state instead and the rebuild never
happens.
Wrapping each word in a reporting node. Correct, and twice the elements: a document is already one widget per word, and ADR-0299 is the record about what that costs when it is multiplied.
Selection in the text stack, by making a paragraph of mixed faces one selectable run. That is the engine — it needs the inline layout ADR-0298 parked — and it would have to wait for it.
Word granularity only, snapping a selection to whole words. Cheaper, and
wrong: the offsets come free from Paragraph.offsetAt, which the editor already
uses for the same purpose.
Copying through the model instead of the rendered words — walk the document tree between two carets and serialise it. Rejected: the carets are positions in what was drawn, the mapping back to the model is a second thing to keep correct, and what a reader selected is what they can see. The words already carry their own separators.