Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

318. A line starts where the paint says it does

Date: 2026-09-15

Status

Accepted. Closes docs/gaps.md G30. Finishes ADR-0256, which gave the paint an alignment and left everything that measures the same text without one.

Context

ADR-0256 put text-align in the one place that had both numbers it needs — a line’s own width and the width of the box — and that place is Paragraph.paint. The indent was a private static method four lines long:

private static double indentOf(double width, double maxWidth, double fraction) {
    if (fraction == 0 || !Double.isFinite(maxWidth)) {
        return 0;
    }
    return Math.max(0, maxWidth - width) * fraction;
}

text.edit.TextGeometry is the other half of the same subject — where is the caret, what did the click land on, what does Up mean — and it measured every x from the paragraph’s origin:

var x = paragraph.widthBetween(line.start(), Math.max(line.start(), offset));

So the painter drew each line indented by its share of the box’s slack and the caret was measured as though no line ever moved. The two parted company the moment the text was not left-aligned: the caret drifted from the glyphs by half the line’s slack under center and by all of it under end, and the drift grew as the line shortened, which on a wrapped paragraph means every line is wrong by a different amount.

An application hit it first, because a board’s default shape is a centred sticky, and it did the only thing it could: wrote the indent rule out a second time and added it to every x it computed and subtracted it from every x it was handed. That works, and it is exactly the duplication docs/gaps.md exists to stop — if Paragraph.paint’s indent ever changes (a justified alignment, an RTL line), the copy is silently wrong and only a round-trip test says so.

It was never only an application’s problem. A text-area whose stylesheet centred it would drift the same way the day anything wires ComputedStyle.textFlow() into the box that draws its value.

Decision

The rule moves to the property, and the geometry is told the alignment.

TextAlign.indentOf(lineWidth, available) is now the one implementation:

public double indentOf(double lineWidth, double available) {
    var fraction = fractionOfSlack();
    if (fraction == 0 || !Double.isFinite(available)) {
        return 0;
    }
    return Math.max(0, available - lineWidth) * fraction;
}

Paragraph.paint calls it. So does every method of TextGeometry, each of which gained a form that takes the width the text was drawn in and the alignment it was drawn with:

TextGeometry.caretAt(paragraph, layout, offset, wrapWidth, TextAlign.CENTER)
TextGeometry.offsetAt(paragraph, layout, x, y, wrapWidth, TextAlign.CENTER)
TextGeometry.moveLine(paragraph, layout, offset, lines, desiredX, wrapWidth, TextAlign.CENTER)
TextGeometry.selectionRects(paragraph, layout, start, end, wrapWidth, TextAlign.CENTER)

The existing shorter forms stay and mean START, which is what every caller written before this assumed.

selectionRects is in the list although the gap did not ask for it. A highlight drifts exactly as a caret does and for the same reason — a selection is geometry the frame already had (ADR-0301) — and three of four corrected would have been a fourth bug waiting.

Which indent comes off which x is the one subtlety. caretAt adds the indent of the line the offset is on. offsetAt subtracts the indent of the line the y lands on, because the x it is given is where the user pressed. moveLine subtracts the target line’s indent from desiredX, not the source line’s, because desiredX is an x a caller read off a Caret and is therefore already in the painted space — that is what makes a run of Down through lines of different lengths keep the column it looks like it is keeping.

Editor carries a textAlign of its own and hands it to all four, so the canvas editor is correct end to end: the paint, the caret, the hit test, Up/Down and the selection move together. Setting it invalidates no layout — alignment changes where a line starts, not where it breaks, which is the whole reason it can be a late decision.

Consequences

An application that had written the rule out a second time deletes it and passes two more arguments. It cannot drift again, because there is no second copy to drift from.

The text-area and text-input controls are unchanged and still ignore text-align altogether: both measure their own carets against their own origin, and both draw their value through Box.text(paragraph, argb) with the default flow, so neither indents its glyphs either. They are consistent today and they are ready — the day one of them passes style.textFlow() down, TextGeometry has the form it needs and the caret follows. Wiring it is not this ADR: those two controls hold the most delicate geometry in the catalog, and a change there deserves its own measurements.

Alternatives considered

Leave the rule in Paragraph and expose it. A public Paragraph.indentOf would be one implementation too, and it would put a property of text-align on the class that happens to paint. TextAlign already owned fractionOfSlack; the distance is the same question one step further on.

Have TextGeometry take a TextFlow rather than a TextAlign. A flow also carries white-space and text-overflow, and neither means anything to a caret: the layout it is handed has already broken the lines, and a truncated line has no caret in the part that was cut. Taking the one value it uses keeps it impossible to pass a flow that disagrees with the layout.

Take an origin instead, and let the caller do the arithmetic. That is what the application’s stopgap was, moved inside the signature — the caller would still need the rule to compute the origin, per line, which is the thing being centralised.