18 KiB
Text Measurement and Interaction APIs
This document describes the public measurement and selection surface for laid-out text. The intent is that callers can measure, render, hit-test, place carets, and draw selections without reimplementing bidi, grapheme, hard-break, or layout mode rules outside the library.
All positional metrics exposed by these APIs are in pixel units.
API Layers
There are four layers:
TextMeasurer: one-shot convenience APIs for measuring a string.TextBlock: prepared text that can be measured or rendered repeatedly.TextMetrics: the full measurement result for one laid-out text block.LineLayout: one laid-out line with line-local measurement and interaction APIs.
Use TextMeasurer for simple one-off work. Use TextBlock when the same text
will be measured, rendered, wrapped, or inspected more than once.
One-Shot Measurement
TextMeasurer is the shortest path from text and options to measurements.
TextOptions.WrappingLength controls wrapping for these methods.
TextOptions options = new(font)
{
Origin = new Vector2(20, 30),
// TextMeasurer reads WrappingLength from TextOptions.
WrappingLength = 320
};
TextMetrics metrics = TextMeasurer.Measure(text, options);
FontRectangle advance = TextMeasurer.MeasureAdvance(text, options);
FontRectangle bounds = TextMeasurer.MeasureBounds(text, options);
FontRectangle renderableBounds = TextMeasurer.MeasureRenderableBounds(text, options);
The aggregate rectangles answer different questions:
MeasureAdvance: the logical line-box advance of the text.MeasureBounds: the rendered glyph bounds.MeasureRenderableBounds: the union of logical advance and rendered glyph bounds.
Use MeasureAdvance for layout flow. Use MeasureBounds for tight ink bounds.
Use MeasureRenderableBounds when both typographic advance and rendered glyph
overshoot must fit.
Prepared Measurement
TextBlock prepares the wrapping-independent text work once. Pass the wrapping
length to each operation. TextOptions.WrappingLength is ignored by the
constructor.
TextBlock block = new(text, options);
// Each operation supplies the wrapping length; the constructor does not.
TextMetrics narrow = block.Measure(240);
TextMetrics wide = block.Measure(480);
FontRectangle narrowBounds = block.MeasureBounds(240);
FontRectangle wideBounds = block.MeasureBounds(480);
Use -1 as the wrapping length to disable wrapping.
// -1 disables wrapping for TextBlock operations.
TextMetrics unwrapped = block.Measure(-1);
TextBlock also exposes direct detail APIs when a full TextMetrics object is
not needed:
ReadOnlyMemory<LineMetrics> lines = block.GetLineMetrics(320);
ReadOnlyMemory<GraphemeMetrics> graphemes = block.GetGraphemeMetrics(320);
ReadOnlyMemory<WordMetrics> words = block.GetWordMetrics(320);
ReadOnlyMemory<GlyphMetrics> glyphs = block.GetGlyphMetrics(320);
Method-returned measurement collections use ReadOnlyMemory<T> because they are
snapshots that callers may store with their own layout state. Owner-backed
properties, such as TextMetrics.LineMetrics and LineLayout.GraphemeMetrics,
use ReadOnlySpan<T> because the owner object already controls the lifetime.
TextMetrics
TextMetrics is the result to keep when callers need several measurements from
the same laid-out text.
TextMetrics metrics = TextMeasurer.Measure(text, options);
// These aggregate measurements answer different layout and rendering questions.
FontRectangle advance = metrics.Advance;
FontRectangle bounds = metrics.Bounds;
FontRectangle renderableBounds = metrics.RenderableBounds;
int lineCount = metrics.LineCount;
ReadOnlySpan<LineMetrics> lines = metrics.LineMetrics;
ReadOnlySpan<GraphemeMetrics> graphemes = metrics.GraphemeMetrics;
ReadOnlySpan<WordMetrics> words = metrics.WordMetrics;
The line and grapheme collections are in final layout order. That matters for bidi text and reverse line-order layout modes: source order and visual order can be different.
WordMetrics are in source order because word-boundary navigation is a logical
text operation. Selection and caret APIs convert those logical metrics back into
visual geometry when needed.
Line Metrics
LineMetrics describes one laid-out line.
foreach (LineMetrics line in metrics.LineMetrics)
{
// Start and Extent describe the positioned line box.
Vector2 start = line.Start;
Vector2 extent = line.Extent;
float baseline = line.Baseline;
}
Start and Extent describe the positioned line box in pixel units. Selection
and caret APIs use the line box for the cross-axis size, which matches normal
text editor and browser behavior: selecting mixed font sizes on the same line
paints a consistent line-height rectangle rather than one rectangle per glyph
height.
StringIndex, GraphemeIndex, and GraphemeCount describe the source text
range owned by the line. GraphemeCount is not a glyph count.
Grapheme Metrics
Use GraphemeMetrics for text interaction: hit testing, caret positioning,
range selection, and UI overlays.
foreach (GraphemeMetrics grapheme in metrics.GraphemeMetrics)
{
// Use Advance for interaction and Bounds for rendered ink.
FontRectangle advance = grapheme.Advance;
FontRectangle bounds = grapheme.Bounds;
FontRectangle renderableBounds = grapheme.RenderableBounds;
bool isLineBreak = grapheme.IsLineBreak;
}
The rectangles answer different questions:
Advance: the positioned logical advance rectangle for the grapheme.Bounds: the rendered glyph bounds for the grapheme.RenderableBounds: the union of advance and rendered glyph bounds.
Use Advance for hit targets, carets, and selection geometry. Ink bounds can be
empty, overhang the advance, or exclude whitespace, so they are not a reliable
interaction target.
IsLineBreak identifies hard-break graphemes that remain in the laid-out
metrics. Hard breaks at the end of non-empty lines are trimmed with other
trailing breaking whitespace; hard breaks that own blank lines remain because
they provide the line geometry for selection and caret behavior.
Word Metrics
WordMetrics describes one Unicode word-boundary segment from UAX #29.
foreach (WordMetrics word in metrics.WordMetrics)
{
FontRectangle advance = word.Advance;
FontRectangle bounds = word.Bounds;
FontRectangle renderableBounds = word.RenderableBounds;
int graphemeStart = word.GraphemeStart;
int graphemeEnd = word.GraphemeEnd;
int stringStart = word.StringStart;
int stringEnd = word.StringEnd;
}
Advance, Bounds, and RenderableBounds have the same meanings as the
equivalent GraphemeMetrics rectangles, but accumulated across the
word-boundary segment. Whitespace segments keep their positioned bounds; they
are not discarded just because they are separators.
All Start values on WordMetrics are inclusive. All End values are exclusive.
GraphemeStart and GraphemeEnd are grapheme insertion indices. StringStart
and StringEnd are UTF-16 indices into the original text.
Unicode word-boundary segments include separators. For example, can't stop
contains three segments:
can't
[space]
stop
This keeps the raw API aligned with the Unicode standard. Higher-level editor commands can choose whether to stop on separator boundaries or skip over them.
Glyph Metrics
Glyph detail APIs expose laid-out glyph entries.
ReadOnlyMemory<GlyphMetrics> glyphs = metrics.GetGlyphMetrics();
foreach (GlyphMetrics glyph in glyphs.Span)
{
FontRectangle advance = glyph.Advance;
FontRectangle bounds = glyph.Bounds;
FontRectangle renderableBounds = glyph.RenderableBounds;
CodePoint codePoint = glyph.CodePoint;
}
Use glyph detail for rendering diagnostics, glyph-level visualization, or advanced inspection. Do not use glyph entries as character or caret positions: ligatures, decomposition, fallback, emoji, and combining marks mean one grapheme can map to multiple glyph entries, and multiple source characters can map to one visual glyph sequence.
Per-Line Layout
TextBlock.GetLineLayouts returns line objects when callers want line-local
inspection or interaction.
TextBlock block = new(text, options);
ReadOnlyMemory<LineLayout> layout = block.GetLineLayouts(320);
foreach (LineLayout line in layout.Span)
{
// LineLayout exposes the slice of grapheme metrics owned by this line.
LineMetrics lineMetrics = line.LineMetrics;
ReadOnlySpan<GraphemeMetrics> lineGraphemes = line.GraphemeMetrics;
}
LineLayout mirrors the interaction and glyph-detail surface for a single line:
TextHit hit = line.HitTest(point);
// Passing the hit keeps trailing-edge and bidi handling inside the library.
CaretPosition caret = line.GetCaretPosition(hit);
CaretPosition next = line.MoveCaret(caret, CaretMovement.Next);
WordMetrics word = line.GetWordMetrics(hit);
ReadOnlyMemory<FontRectangle> selection = line.GetSelectionBounds(caret, next);
ReadOnlyMemory<FontRectangle> wordSelection = line.GetSelectionBounds(word);
ReadOnlyMemory<GlyphMetrics> glyphs = line.GetGlyphMetrics();
Use the full TextMetrics interaction methods for selections that can cross
line boundaries. Use LineLayout when the caller already knows interaction is
line-local.
Hit Testing
Hit testing maps a point to the nearest grapheme and side.
TextHit hit = metrics.HitTest(mousePosition);
int lineIndex = hit.LineIndex;
int graphemeIndex = hit.GraphemeIndex;
// Use this value for carets and selection endpoints.
int insertionIndex = hit.GraphemeInsertionIndex;
GraphemeIndex identifies the hit grapheme. GraphemeInsertionIndex identifies
the logical caret position represented by the hit. For left-to-right text, the
trailing side is usually GraphemeIndex + 1. For right-to-left text, the
physical side is reversed, but callers do not need to apply that rule. Use
GraphemeInsertionIndex or pass the TextHit directly to caret and selection
APIs.
For word selection, pass the hit directly to GetWordMetrics. This uses the
grapheme that was hit, so clicking the trailing side of the final grapheme in a
word still selects that word rather than the following separator segment.
TextHit hit = metrics.HitTest(mousePosition);
WordMetrics word = metrics.GetWordMetrics(hit);
ReadOnlyMemory<FontRectangle> selection = metrics.GetSelectionBounds(word);
Caret Positioning
Caret APIs return positioned caret lines in pixel units. A caret is also the navigation token for keyboard/editor interaction.
TextHit hit = metrics.HitTest(mousePosition);
// The hit overload applies the correct grapheme insertion index.
CaretPosition caret = metrics.GetCaretPosition(hit);
DrawCaret(caret.Start, caret.End);
if (caret.HasSecondary)
{
DrawSecondaryCaret(caret.SecondaryStart, caret.SecondaryEnd);
}
Use absolute placement when initializing a keyboard caret without a pointer hit.
CaretPosition caret = metrics.GetCaret(CaretPlacement.Start);
At bidi boundaries, one logical insertion position can have two visual edges.
CaretPosition exposes the secondary edge so editor-style callers can choose how
to present or navigate that boundary without recomputing bidi affinity.
Caret Movement
MoveCaret applies editor-style movement to a caret and returns the new caret.
CaretPosition caret = metrics.GetCaret(CaretPlacement.Start);
// Previous and Next move through logical grapheme insertion positions.
caret = metrics.MoveCaret(caret, CaretMovement.Next);
// PreviousWord and NextWord move through Unicode word boundaries.
caret = metrics.MoveCaret(caret, CaretMovement.NextWord);
// LineStart and LineEnd are the Home/End-style line movement operations.
caret = metrics.MoveCaret(caret, CaretMovement.LineEnd);
// TextStart and TextEnd are the whole-block equivalents.
caret = metrics.MoveCaret(caret, CaretMovement.TextStart);
LineUp and LineDown move to adjacent visual lines while preserving the
caret's requested position on the line.
CaretPosition firstLineEnd = metrics.GetCaret(CaretPlacement.Start);
firstLineEnd = metrics.MoveCaret(firstLineEnd, CaretMovement.LineEnd);
// Repeated LineDown keeps the original line position even when an intermediate
// line is shorter and the visible caret has to clamp to that line's end.
CaretPosition middleLine = metrics.MoveCaret(firstLineEnd, CaretMovement.LineDown);
CaretPosition finalLine = metrics.MoveCaret(middleLine, CaretMovement.LineDown);
This preserves normal rich-text editor behavior: moving down through a short line does not permanently lose the user's original horizontal or vertical line position.
Selection Bounds
Selection APIs return rectangles in visual order and pixel units. The result is
ReadOnlyMemory<FontRectangle> so callers can store it with selection state and
use .Span when drawing.
For pointer selection, use the hit overload. This keeps bidi and trailing-edge logic inside the library.
TextHit anchor = metrics.HitTest(mouseDown);
TextHit focus = metrics.HitTest(mouseMove);
// The hit overload converts both endpoints to logical insertion indices.
ReadOnlyMemory<FontRectangle> selection = metrics.GetSelectionBounds(anchor, focus);
foreach (FontRectangle rectangle in selection.Span)
{
FillSelectionRectangle(rectangle);
}
For keyboard selection, keep an anchor caret and move the focus caret.
CaretPosition anchor = metrics.GetCaret(CaretPlacement.Start);
CaretPosition focus = anchor;
// Shift+Right-style behavior updates only the focus caret.
focus = metrics.MoveCaret(focus, CaretMovement.Next);
ReadOnlyMemory<FontRectangle> selection = metrics.GetSelectionBounds(anchor, focus);
For word selection, use the word metrics overload.
TextHit hit = metrics.HitTest(doubleClickPosition);
WordMetrics word = metrics.GetWordMetrics(hit);
ReadOnlyMemory<FontRectangle> selection = metrics.GetSelectionBounds(word);
Do not sort, union, or merge the returned rectangles unless the UI explicitly wants a different visual. A single logical selection can be visually discontinuous inside one line when it crosses bidi runs. Returning multiple rectangles allows browser-style selection where the unselected visual gap stays unpainted.
Bidi Drag Selection
Consider a line whose source text is:
Tall שלום عرب
In a left-to-right paragraph, the right-to-left run can paint with Arabic before
Hebrew. When a user drags from the left edge of Tall toward the Hebrew word,
the selection can become visually split:
[Tall ] عرب [שלום]
Application code should not manually decide which physical edge of the Hebrew glyph means "before" or "after". The correct flow is:
TextHit anchor = metrics.HitTest(mouseDown);
TextHit focus = metrics.HitTest(mouseMove);
// Bidi split selection is represented by the returned rectangle list.
ReadOnlyMemory<FontRectangle> rectangles = metrics.GetSelectionBounds(anchor, focus);
The hit-test result carries the logical insertion index. The selection result is already split into the visual rectangles that should be painted.
Hard Line Breaks
Hard line breaks that end non-empty lines are trimmed with trailing breaking whitespace. Hard line breaks that own blank lines remain as graphemes for source ranges, hit testing, caret movement, and selection painting.
For text with two hard breaks in the middle:
Tall عرب שלום
Small مرحبا שלום
Full selection should paint three visual rows: the first text line, the blank line, and the second text line. The line break that ends a non-empty line should not add a separate painted box; the line break that owns the blank line should.
Consumers should not special-case this. Draw the rectangles returned by
GetSelectionBounds. Consumers that inspect individual graphemes can use
IsLineBreak to identify the blank-line hard breaks that remain in the metrics.
Recommended Workflows
For one-off measuring:
// One-shot path for a single layout result.
TextMetrics metrics = TextMeasurer.Measure(text, options);
For repeated wrapping or rendering:
TextBlock block = new(text, options);
// Reuse the prepared text for each requested wrapping length.
TextMetrics narrow = block.Measure(240);
TextMetrics wide = block.Measure(480);
block.RenderTo(renderer, 480);
For text editor interaction:
TextMetrics metrics = block.Measure(wrappingLength);
TextHit anchor = metrics.HitTest(mouseDown);
TextHit focus = metrics.HitTest(mouseMove);
// Use hit-based overloads so interaction follows the laid-out bidi result.
CaretPosition caret = metrics.GetCaretPosition(focus);
ReadOnlyMemory<FontRectangle> selection = metrics.GetSelectionBounds(anchor, focus);
For keyboard navigation and selection:
TextMetrics metrics = block.Measure(wrappingLength);
CaretPosition caret = metrics.GetCaret(CaretPlacement.Start);
CaretPosition anchor = caret;
// The movement operation owns grapheme, line, and hard-break navigation rules.
caret = metrics.MoveCaret(caret, CaretMovement.LineDown);
caret = metrics.MoveCaret(caret, CaretMovement.NextWord);
ReadOnlyMemory<FontRectangle> selection = metrics.GetSelectionBounds(anchor, caret);
For per-line UI:
ReadOnlyMemory<LineLayout> lines = block.GetLineLayouts(wrappingLength);
foreach (LineLayout line in lines.Span)
{
ReadOnlySpan<GraphemeMetrics> graphemes = line.GraphemeMetrics;
ReadOnlyMemory<GlyphMetrics> glyphs = line.GetGlyphMetrics();
}
Design Principles
- The library owns bidi, grapheme, hard-break, wrapping, and layout-mode rules.
- Callers should pass points, hits, or logical ranges and draw the returned geometry.
- Caret movement should flow through
MoveCaret, not caller-side grapheme arithmetic. - Word selection should flow through
GetWordMetrics, not caller-side Unicode boundary logic. - Grapheme metrics are the text interaction unit.
- Word metrics describe logical source segments and their positioned geometry; selection bounds are the visual geometry.
- Glyph metrics are rendering-detail data, not caret or character data.
- Selection rectangles are visual geometry, not a single logical union.
- Per-line selection uses line-box height so selection remains visually stable across mixed fonts and font sizes.