// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System;
using System.Numerics;
namespace SixLabors.Fonts {
///
/// Provides shared helpers for text interaction metrics.
///
///
/// Text interaction uses grapheme advance rectangles as the logical hit target. Ink bounds can be
/// empty, overhang the advance, or exclude whitespace, which makes them unsuitable for caret
/// positioning and selection highlighting.
///
internal static class TextInteraction
{
///
/// Hit tests a point against a complete laid-out text box.
///
/// All laid-out lines ordered by their visual position.
/// The full grapheme metrics buffer flattened in visual order.
/// The text-space coordinate to resolve to a grapheme hit.
/// The orientation used to interpret the line and grapheme advances.
/// The nearest grapheme hit.
public static TextHit HitTest(
ReadOnlySpan lines,
ReadOnlySpan graphemes,
Vector2 point,
LayoutMode layoutMode)
{
if (lines.IsEmpty || graphemes.IsEmpty)
{
return new(-1, -1, -1, false);
}
bool isHorizontal = layoutMode.IsHorizontal();
int lineIndex = FindLine(lines, point, isHorizontal);
// LineMetrics preserve their source line index, while grapheme metrics are emitted in
// visual line order. Locate the line slice by source range so reverse line-order modes
// pair the hit-tested line with its own graphemes.
int graphemeOffset = GetGraphemeOffset(lines[lineIndex]);
ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, lines[lineIndex].GraphemeCount);
return HitTestLine(lineIndex, lineGraphemes, point, isHorizontal);
}
///
/// Hit tests a point against one laid-out line.
///
/// The zero-based visual index of the line being hit tested.
/// Only the grapheme metrics belonging to the target line.
/// The coordinate to compare against the line's primary advance axis.
/// The line orientation that determines which axis is primary.
/// The nearest grapheme hit.
public static TextHit HitTestLine(
int lineIndex,
ReadOnlySpan graphemes,
Vector2 point,
LayoutMode layoutMode)
=> HitTestLine(lineIndex, graphemes, point, layoutMode.IsHorizontal());
///
/// Gets a caret position from a complete laid-out text box.
///
/// All laid-out lines available for caret placement.
/// The flattened grapheme metrics that back the full text box.
/// The logical insertion position to convert into a visual caret.
/// The layout orientation used when the caret geometry was calculated.
/// The caret position in pixel units.
public static CaretPosition GetCaretPosition(
ReadOnlySpan lines,
ReadOnlySpan graphemes,
int graphemeIndex,
LayoutMode layoutMode)
{
if (lines.IsEmpty || graphemes.IsEmpty)
{
return new(-1, -1, -1, default, default, false, default, default, 0);
}
int lineIndex = FindLineByGraphemeIndex(lines, graphemeIndex);
LineMetrics line = lines[lineIndex];
// See HitTest: line source indices and flattened storage offsets are deliberately
// separate because bidi reordering can make source order differ from visual order.
int graphemeOffset = GetGraphemeOffset(line);
ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, line.GraphemeCount);
return GetCaretPositionLine(lineIndex, line, lineGraphemes, graphemeIndex, layoutMode);
}
///
/// Gets a caret position from one laid-out line.
///
/// The zero-based visual index of the supplied line.
/// The metrics for the single line that will host the caret.
/// The visual-order grapheme metrics for that one line.
/// The logical insertion position to place within the supplied line.
/// The orientation that determines the caret edge direction.
/// The caret position in pixel units.
public static CaretPosition GetCaretPositionLine(
int lineIndex,
in LineMetrics line,
ReadOnlySpan graphemes,
int graphemeIndex,
LayoutMode layoutMode)
{
if (graphemes.IsEmpty)
{
return new(lineIndex, line.GraphemeIndex, line.StringIndex, default, default, false, default, default, 0);
}
return CreateCaret(lineIndex, line, graphemes, graphemeIndex, layoutMode.IsHorizontal());
}
///
/// Gets an absolute caret position from a complete laid-out text box.
///
/// All laid-out lines available for caret placement.
/// The flattened grapheme metrics that back the full text box.
/// The absolute placement within the text box.
/// The layout orientation used when the caret geometry was calculated.
/// The resolved text direction used to choose the visual start or end of the scope.
/// The caret position in pixel units.
public static CaretPosition GetCaret(
ReadOnlySpan lines,
ReadOnlySpan graphemes,
CaretPlacement placement,
LayoutMode layoutMode,
TextDirection direction)
{
if (lines.IsEmpty || graphemes.IsEmpty)
{
return new(-1, -1, -1, default, default, false, default, default, 0);
}
int targetGraphemeIndex = placement == CaretPlacement.Start
? GetSourceTextStart(graphemes)
: GetSourceTextEnd(graphemes);
int lineIndex = FindLineByGraphemeIndex(lines, targetGraphemeIndex);
LineMetrics line = lines[lineIndex];
int graphemeOffset = GetGraphemeOffset(line);
ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, line.GraphemeCount);
return GetCaretLine(lineIndex, line, lineGraphemes, placement, layoutMode, direction);
}
///
/// Gets an absolute caret position from one laid-out line.
///
/// The zero-based visual index of the supplied line.
/// The metrics for the single line that will host the caret.
/// The visual-order grapheme metrics for that one line.
/// The absolute placement within the line.
/// The orientation that determines the caret edge direction.
/// The resolved text direction used to choose the visual start or end of the scope.
/// The caret position in pixel units.
public static CaretPosition GetCaretLine(
int lineIndex,
in LineMetrics line,
ReadOnlySpan graphemes,
CaretPlacement placement,
LayoutMode layoutMode,
TextDirection direction)
{
if (graphemes.IsEmpty)
{
return new(lineIndex, line.GraphemeIndex, line.StringIndex, default, default, false, default, default, 0);
}
return CreateCaretAtVisualLineEdge(lineIndex, line, graphemes, placement, layoutMode.IsHorizontal(), direction);
}
///
/// Moves a caret within a complete laid-out text box.
///
/// The visual lines across which the caret may move.
/// The flattened grapheme metrics used to resolve movement targets.
/// The source-order word-boundary segment metrics used for word movement.
/// The starting caret location before applying the movement.
/// The requested caret navigation command.
/// The orientation rules that control horizontal versus vertical motion.
/// The resolved text direction used to choose line and text start/end.
/// The moved caret position in pixel units.
public static CaretPosition MoveCaret(
ReadOnlySpan lines,
ReadOnlySpan graphemes,
ReadOnlySpan wordMetrics,
CaretPosition caret,
CaretMovement movement,
LayoutMode layoutMode,
TextDirection direction)
{
if (lines.IsEmpty || graphemes.IsEmpty)
{
return caret;
}
bool isHorizontal = layoutMode.IsHorizontal();
int lineIndex = GetCaretLineIndex(lines, graphemes, caret);
LineMetrics line = lines[lineIndex];
int graphemeOffset = GetGraphemeOffset(line);
ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, line.GraphemeCount);
int target = caret.GraphemeIndex;
switch (movement)
{
case CaretMovement.Previous:
target = GetPreviousInsertionIndex(graphemes, caret.GraphemeIndex, GetSourceTextStart(graphemes));
break;
case CaretMovement.Next:
target = GetNextInsertionIndex(graphemes, caret.GraphemeIndex, GetSourceTextEnd(graphemes));
break;
case CaretMovement.PreviousWord:
target = GetPreviousWordBoundary(wordMetrics, caret.GraphemeIndex, GetSourceTextStart(graphemes));
break;
case CaretMovement.NextWord:
target = GetNextWordBoundary(wordMetrics, caret.GraphemeIndex, GetSourceTextEnd(graphemes));
break;
case CaretMovement.LineStart:
return GetCaretLine(lineIndex, line, lineGraphemes, CaretPlacement.Start, layoutMode, direction);
case CaretMovement.LineEnd:
return GetCaretLine(lineIndex, line, lineGraphemes, CaretPlacement.End, layoutMode, direction);
case CaretMovement.TextStart:
return GetCaret(lines, graphemes, CaretPlacement.Start, layoutMode, direction);
case CaretMovement.TextEnd:
return GetCaret(lines, graphemes, CaretPlacement.End, layoutMode, direction);
case CaretMovement.LineUp:
return MoveCaretToAdjacentLine(
lines,
graphemes,
caret,
lineIndex,
lineDown: false,
isHorizontal: isHorizontal,
layoutMode: layoutMode);
case CaretMovement.LineDown:
return MoveCaretToAdjacentLine(
lines,
graphemes,
caret,
lineIndex,
lineDown: true,
isHorizontal: isHorizontal,
layoutMode: layoutMode);
}
return GetCaretPosition(lines, graphemes, target, layoutMode);
}
///
/// Moves a caret within one laid-out line.
///
/// The zero-based visual index of the current line.
/// The line metrics that constrain the movement.
/// The grapheme metrics available within that line.
/// The source-order word-boundary segment metrics used for word movement.
/// The caret location to move inside the line.
/// The in-line caret navigation command to execute.
/// The orientation used to choose the caret axis within the line.
/// The resolved text direction used to choose line start/end.
/// The moved caret position in pixel units.
public static CaretPosition MoveCaretLine(
int lineIndex,
in LineMetrics line,
ReadOnlySpan graphemes,
ReadOnlySpan wordMetrics,
CaretPosition caret,
CaretMovement movement,
LayoutMode layoutMode,
TextDirection direction)
{
if (graphemes.IsEmpty)
{
return caret;
}
int lineStart = GetSourceLineStart(graphemes);
int lineEnd = GetSourceLineEnd(graphemes);
int target = caret.GraphemeIndex;
switch (movement)
{
case CaretMovement.Previous:
target = GetPreviousInsertionIndex(graphemes, caret.GraphemeIndex, lineStart);
break;
case CaretMovement.Next:
target = GetNextInsertionIndex(graphemes, caret.GraphemeIndex, lineEnd);
break;
case CaretMovement.PreviousWord:
target = Math.Max(
lineStart,
GetPreviousWordBoundary(wordMetrics, caret.GraphemeIndex, lineStart));
break;
case CaretMovement.NextWord:
target = Math.Min(
lineEnd,
GetNextWordBoundary(wordMetrics, caret.GraphemeIndex, lineEnd));
break;
case CaretMovement.LineStart:
case CaretMovement.TextStart:
return GetCaretLine(lineIndex, line, graphemes, CaretPlacement.Start, layoutMode, direction);
case CaretMovement.LineEnd:
case CaretMovement.TextEnd:
return GetCaretLine(lineIndex, line, graphemes, CaretPlacement.End, layoutMode, direction);
case CaretMovement.LineUp:
case CaretMovement.LineDown:
return caret;
}
return GetCaretPositionLine(lineIndex, line, graphemes, target, layoutMode);
}
///
/// Gets the word-boundary segment metrics containing the supplied grapheme insertion index.
///
/// The source-order word metrics to search.
/// The grapheme insertion index to locate.
/// The matching word metrics.
public static WordMetrics GetWordMetrics(ReadOnlySpan wordMetrics, int graphemeIndex)
{
if (wordMetrics.IsEmpty)
{
return default;
}
for (int i = 0; i < wordMetrics.Length; i++)
{
WordMetrics metrics = wordMetrics[i];
if (graphemeIndex >= metrics.GraphemeStart && graphemeIndex < metrics.GraphemeEnd)
{
return metrics;
}
if (graphemeIndex < metrics.GraphemeStart)
{
return metrics;
}
}
return wordMetrics[^1];
}
///
/// Gets selection rectangles from a complete laid-out text box.
///
/// The visual lines that may contribute selection rectangles.
/// The flattened grapheme metrics scanned for the selected range.
/// The first source grapheme insertion boundary in the selection.
/// The final source grapheme insertion boundary in the selection.
/// The orientation used when converting ranges into rectangles.
/// A read-only memory region containing the selection rectangles in visual order.
public static ReadOnlyMemory GetSelectionBounds(
ReadOnlySpan lines,
ReadOnlySpan graphemes,
int graphemeStart,
int graphemeEnd,
LayoutMode layoutMode)
{
if (lines.IsEmpty || graphemes.IsEmpty || graphemeStart == graphemeEnd)
{
return ReadOnlyMemory.Empty;
}
int selectionStart = Math.Min(graphemeStart, graphemeEnd);
int selectionEnd = Math.Max(graphemeStart, graphemeEnd);
int rectangleCount = CountSelectionBounds(lines, graphemes, selectionStart, selectionEnd);
if (rectangleCount == 0)
{
return ReadOnlyMemory.Empty;
}
FontRectangle[] result = new FontRectangle[rectangleCount];
int count = 0;
bool isHorizontal = layoutMode.IsHorizontal();
for (int i = 0; i < lines.Length; i++)
{
LineMetrics line = lines[i];
int graphemeOffset = GetGraphemeOffset(line);
ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, line.GraphemeCount);
if (CountSelectionBoundsLine(lineGraphemes, selectionStart, selectionEnd) == 0)
{
continue;
}
count += FillSelectionBoundsLine(line, lineGraphemes, selectionStart, selectionEnd, isHorizontal, result.AsSpan(count));
}
return result;
}
///
/// Gets selection rectangles for one laid-out line.
///
/// The single line for which selection rectangles are produced.
/// The line-local grapheme metrics scanned in visual order.
/// The first source grapheme insertion boundary applied to this line.
/// The final source grapheme insertion boundary applied to this line.
/// The orientation used to map the selected run onto the line box.
/// A read-only memory region containing the line selection rectangles in visual order.
public static ReadOnlyMemory GetSelectionBoundsLine(
in LineMetrics line,
ReadOnlySpan graphemes,
int graphemeStart,
int graphemeEnd,
LayoutMode layoutMode)
{
if (graphemes.IsEmpty || graphemeStart == graphemeEnd)
{
return ReadOnlyMemory.Empty;
}
int selectionStart = Math.Min(graphemeStart, graphemeEnd);
int selectionEnd = Math.Max(graphemeStart, graphemeEnd);
int count = CountSelectionBoundsLine(graphemes, selectionStart, selectionEnd);
if (count == 0)
{
return ReadOnlyMemory.Empty;
}
FontRectangle[] result = new FontRectangle[count];
_ = FillSelectionBoundsLine(line, graphemes, selectionStart, selectionEnd, layoutMode.IsHorizontal(), result);
return result;
}
///
/// Gets selection bounds for one measured grapheme.
///
/// The visual lines used to find the grapheme's line box.
/// The flattened grapheme metrics that back the full text box.
/// The measured grapheme to select.
/// The orientation used to map the grapheme advance onto the line box.
/// A read-only memory region containing the grapheme selection bounds.
public static ReadOnlyMemory GetSelectionBounds(
ReadOnlySpan lines,
ReadOnlySpan graphemes,
in GraphemeMetrics grapheme,
LayoutMode layoutMode)
{
if (lines.IsEmpty || graphemes.IsEmpty)
{
return ReadOnlyMemory.Empty;
}
int lineIndex = FindLineByGraphemeIndex(lines, grapheme.GraphemeIndex);
FontRectangle[] result = [CreateSelectionBounds(lines[lineIndex], grapheme, layoutMode.IsHorizontal())];
return result;
}
///
/// Gets selection bounds for one measured grapheme within one laid-out line.
///
/// The line that provides the cross-axis selection extent.
/// The measured grapheme to select.
/// The orientation used to map the grapheme advance onto the line box.
/// A read-only memory region containing the grapheme selection bounds.
public static ReadOnlyMemory GetSelectionBoundsLine(
in LineMetrics line,
in GraphemeMetrics grapheme,
LayoutMode layoutMode)
{
FontRectangle[] result = [CreateSelectionBounds(line, grapheme, layoutMode.IsHorizontal())];
return result;
}
///
/// Finds the visual line nearest to a point.
///
/// The candidate visual lines to compare with the point.
/// The coordinate whose cross-axis position selects the nearest line.
/// Indicates whether line advances are measured along the x-axis.
/// The nearest line index.
private static int FindLine(
ReadOnlySpan lines,
Vector2 point,
bool isHorizontal)
{
float cross = isHorizontal ? point.Y : point.X;
for (int i = 0; i < lines.Length; i++)
{
float lineStart = isHorizontal ? lines[i].Start.Y : lines[i].Start.X;
float lineEnd = isHorizontal ? lines[i].Start.Y + lines[i].Extent.Y : lines[i].Start.X + lines[i].Extent.X;
if (cross >= lineStart && cross < lineEnd)
{
return i;
}
}
float lineFirstStart = isHorizontal ? lines[0].Start.Y : lines[0].Start.X;
return cross < lineFirstStart ? 0 : lines.Length - 1;
}
///
/// Finds the line that owns the supplied grapheme index.
///
/// The visual lines whose source ranges are searched.
/// The source grapheme index to locate.
/// The nearest owning line index.
private static int FindLineByGraphemeIndex(
ReadOnlySpan lines,
int graphemeIndex)
{
for (int i = 0; i < lines.Length; i++)
{
LineMetrics line = lines[i];
int lineStart = line.GraphemeIndex;
int lineEnd = lineStart + line.GraphemeCount;
if (graphemeIndex >= lineStart && graphemeIndex <= lineEnd)
{
return i;
}
}
return 0;
}
///
/// Hit tests a point against one laid-out line after the layout mode has been normalized.
///
/// The zero-based visual index of the normalized line.
/// The grapheme metrics already isolated for that line.
/// The coordinate to compare with each grapheme advance rectangle.
/// Indicates whether the primary hit-test axis is horizontal.
/// The nearest grapheme hit.
private static TextHit HitTestLine(
int lineIndex,
ReadOnlySpan graphemes,
Vector2 point,
bool isHorizontal)
{
int index = FindNearestGrapheme(graphemes, isHorizontal ? point.X : point.Y, isHorizontal);
GraphemeMetrics grapheme = graphemes[index];
FontRectangle advance = grapheme.Advance;
float midpoint = isHorizontal
? advance.Left + (advance.Width * 0.5F)
: advance.Top + (advance.Height * 0.5F);
float primary = isHorizontal ? point.X : point.Y;
bool trailing = IsRightToLeft(grapheme)
? primary < midpoint
: primary >= midpoint;
return new(lineIndex, grapheme.GraphemeIndex, grapheme.StringIndex, trailing);
}
///
/// Creates a caret line for a grapheme insertion index.
///
/// The zero-based visual index of the caret's line.
/// The line metrics used to size the caret segment.
/// The line-local grapheme metrics searched for neighboring edges.
/// The logical insertion position to materialize as a caret.
/// Indicates whether the caret spans vertically or horizontally.
/// The caret position in pixel units.
private static CaretPosition CreateCaret(
int lineIndex,
in LineMetrics line,
ReadOnlySpan graphemes,
int graphemeIndex,
bool isHorizontal)
{
int previousIndex = FindGraphemeBySourceIndex(graphemes, graphemeIndex - 1);
int nextIndex = FindGraphemeBySourceIndex(graphemes, graphemeIndex);
if (nextIndex < 0 && previousIndex < 0)
{
int nearestIndex = FindNearestGraphemeIndex(graphemes, graphemeIndex);
GraphemeMetrics nearest = graphemes[nearestIndex];
bool trailing = graphemeIndex > nearest.GraphemeIndex;
CreateCaretEdge(line, nearest, trailing, isHorizontal, out Vector2 start, out Vector2 end);
return new(
lineIndex,
graphemeIndex,
nearest.StringIndex,
start,
end,
false,
default,
default,
GetLineNavigationPosition(start, isHorizontal));
}
if (nextIndex >= 0)
{
GraphemeMetrics next = graphemes[nextIndex];
CreateCaretEdge(line, next, trailing: false, isHorizontal, out Vector2 start, out Vector2 end);
if (previousIndex >= 0)
{
GraphemeMetrics previous = graphemes[previousIndex];
CreateCaretEdge(line, previous, trailing: true, isHorizontal, out Vector2 secondaryStart, out Vector2 secondaryEnd);
// At a bidi boundary the same logical insertion point has one visual edge on
// each neighboring run. Return both instead of asking callers to choose affinity.
if (start != secondaryStart || end != secondaryEnd)
{
return new(
lineIndex,
graphemeIndex,
next.StringIndex,
start,
end,
true,
secondaryStart,
secondaryEnd,
GetLineNavigationPosition(start, isHorizontal));
}
}
return new(
lineIndex,
graphemeIndex,
next.StringIndex,
start,
end,
false,
default,
default,
GetLineNavigationPosition(start, isHorizontal));
}
GraphemeMetrics previousOnly = graphemes[previousIndex];
// Editor-mode hard breaks can create a blank visual line whose only source
// ownership is the preceding newline grapheme. A caret requested immediately
// after that grapheme should sit at the start of the blank line, not after
// the newline marker's trimmed layout box.
if (previousOnly.IsLineBreak && graphemeIndex == previousOnly.GraphemeIndex + 1)
{
Vector2 start;
Vector2 end;
if (isHorizontal)
{
float x = IsRightToLeft(previousOnly) ? line.Start.X + line.Extent.X : line.Start.X;
start = new Vector2(x, line.Start.Y);
end = new Vector2(x, line.Start.Y + line.Extent.Y);
}
else
{
float y = IsRightToLeft(previousOnly) ? line.Start.Y + line.Extent.Y : line.Start.Y;
start = new Vector2(line.Start.X, y);
end = new Vector2(line.Start.X + line.Extent.X, y);
}
// The newline grapheme gives the blank line source ownership, but the
// editable insertion point after Enter belongs at the new line start.
return new(
lineIndex,
graphemeIndex,
previousOnly.StringIndex,
start,
end,
false,
default,
default,
GetLineNavigationPosition(start, isHorizontal));
}
CreateCaretEdge(line, previousOnly, trailing: true, isHorizontal, out Vector2 primaryStart, out Vector2 primaryEnd);
return new(
lineIndex,
graphemeIndex,
previousOnly.StringIndex,
primaryStart,
primaryEnd,
false,
default,
default,
GetLineNavigationPosition(primaryStart, isHorizontal));
}
///
/// Creates one visual caret edge for a grapheme.
///
/// The containing line that defines the caret span.
/// The grapheme whose leading or trailing edge is used.
/// Specifies whether the logical trailing side should be chosen.
/// Indicates whether caret edges vary along the x-axis.
/// Receives the first endpoint of the caret segment.
/// Receives the second endpoint of the caret segment.
private static void CreateCaretEdge(
in LineMetrics line,
in GraphemeMetrics grapheme,
bool trailing,
bool isHorizontal,
out Vector2 start,
out Vector2 end)
{
FontRectangle advance = grapheme.Advance;
bool useEnd = IsRightToLeft(grapheme) ? !trailing : trailing;
if (isHorizontal)
{
// Bidi layout can produce negative advance widths. Left/Right are
// rectangle construction edges in that case, so choose the physical
// min/max x edge after logical leading/trailing has been resolved.
float physicalStart = MathF.Min(advance.Left, advance.Right);
float physicalEnd = MathF.Max(advance.Left, advance.Right);
float x = useEnd ? physicalEnd : physicalStart;
start = new Vector2(x, line.Start.Y);
end = new Vector2(x, line.Start.Y + line.Extent.Y);
return;
}
float physicalTop = MathF.Min(advance.Top, advance.Bottom);
float physicalBottom = MathF.Max(advance.Top, advance.Bottom);
float y = useEnd ? physicalBottom : physicalTop;
start = new Vector2(line.Start.X, y);
end = new Vector2(line.Start.X + line.Extent.X, y);
}
///
/// Creates a caret at the source start or end boundary of a laid-out line.
///
/// The zero-based visual index of the line.
/// The line metrics used to size the caret segment.
/// The line-local grapheme metrics in visual order.
/// The source boundary to place within the line.
/// Indicates whether the caret spans vertically or horizontally.
/// The resolved text direction used to choose the visual start or end of the scope.
/// The caret position at the requested line boundary.
private static CaretPosition CreateCaretAtVisualLineEdge(
int lineIndex,
in LineMetrics line,
ReadOnlySpan graphemes,
CaretPlacement placement,
bool isHorizontal,
TextDirection direction)
{
bool isStart = placement == CaretPlacement.Start;
int insertionIndex = isStart ? GetSourceLineStart(graphemes) : GetSourceLineEnd(graphemes);
int visualIndex = FindGraphemeBySourceIndex(graphemes, isStart ? insertionIndex : insertionIndex - 1);
GraphemeMetrics grapheme = graphemes[visualIndex];
bool isRightToLeft = direction == TextDirection.RightToLeft;
bool useEnd = isStart == isRightToLeft;
// Start/end placement is anchored to the source boundary grapheme for
// the returned insertion index, but the visible caret sits on the line
// box edge. The resolved paragraph direction chooses which physical
// line edge represents start or end.
Vector2 start;
Vector2 end;
if (isHorizontal)
{
float x = useEnd ? line.Start.X + line.Extent.X : line.Start.X;
start = new Vector2(x, line.Start.Y);
end = new Vector2(x, line.Start.Y + line.Extent.Y);
}
else
{
float y = useEnd ? line.Start.Y + line.Extent.Y : line.Start.Y;
start = new Vector2(line.Start.X, y);
end = new Vector2(line.Start.X + line.Extent.X, y);
}
return new(
lineIndex,
insertionIndex,
grapheme.StringIndex,
start,
end,
false,
default,
default,
GetLineNavigationPosition(start, isHorizontal));
}
///
/// Moves the caret to the nearest matching position on an adjacent visual line.
///
/// The set of visual lines available for adjacent-line navigation.
/// The flattened grapheme metrics used to resolve the new caret target.
/// The caret location before moving to the neighbor line.
/// The visual index of the line that currently contains the caret.
/// Specifies whether movement is toward the next visual line.
/// Indicates whether preserved column data uses the x-axis.
/// The orientation used when reconstructing the destination caret.
/// The moved caret position in pixel units.
private static CaretPosition MoveCaretToAdjacentLine(
ReadOnlySpan lines,
ReadOnlySpan graphemes,
CaretPosition caret,
int lineIndex,
bool lineDown,
bool isHorizontal,
LayoutMode layoutMode)
{
int targetLineIndex = FindAdjacentLine(lines, lineIndex, lineDown, isHorizontal);
if (targetLineIndex == lineIndex)
{
return caret;
}
LineMetrics targetLine = lines[targetLineIndex];
int graphemeOffset = GetGraphemeOffset(targetLine);
ReadOnlySpan targetGraphemes = graphemes.Slice(graphemeOffset, targetLine.GraphemeCount);
Vector2 hitPoint = isHorizontal
? new(caret.LineNavigationPosition, targetLine.Start.Y + (targetLine.Extent.Y * 0.5F))
: new(targetLine.Start.X + (targetLine.Extent.X * 0.5F), caret.LineNavigationPosition);
TextHit hit = HitTestLineForCaretNavigation(targetLineIndex, targetGraphemes, hitPoint, isHorizontal);
CaretPosition moved = GetCaretPositionLine(
targetLineIndex,
targetLine,
targetGraphemes,
hit.GraphemeInsertionIndex,
layoutMode);
// Preserve the original requested line position so repeated LineUp/LineDown movement
// returns to the same visual column after passing through shorter lines.
return WithLineNavigationPosition(moved, caret.LineNavigationPosition);
}
///
/// Hit tests a line for keyboard caret navigation.
///
/// The zero-based visual index of the line being navigated.
/// The line-local grapheme metrics considered as navigation targets.
/// The projected point used to preserve visual column alignment.
/// Indicates whether navigation compares x coordinates first.
/// The nearest grapheme hit.
private static TextHit HitTestLineForCaretNavigation(
int lineIndex,
ReadOnlySpan graphemes,
Vector2 point,
bool isHorizontal)
{
int index = FindNearestCaretNavigationGrapheme(graphemes, isHorizontal ? point.X : point.Y, isHorizontal);
GraphemeMetrics grapheme = graphemes[index];
FontRectangle advance = grapheme.Advance;
float midpoint = isHorizontal
? advance.Left + (advance.Width * 0.5F)
: advance.Top + (advance.Height * 0.5F);
float primary = isHorizontal ? point.X : point.Y;
bool trailing = IsRightToLeft(grapheme)
? primary < midpoint
: primary >= midpoint;
return new(lineIndex, grapheme.GraphemeIndex, grapheme.StringIndex, trailing);
}
///
/// Finds the nearest grapheme that should participate in keyboard caret navigation.
///
/// The visual-order graphemes filtered for caret navigation.
/// The coordinate on the primary advance axis to compare.
/// Indicates whether the primary axis maps to horizontal movement.
/// The nearest grapheme metrics index within .
private static int FindNearestCaretNavigationGrapheme(
ReadOnlySpan graphemes,
float primary,
bool isHorizontal)
{
int first = -1;
int last = -1;
for (int i = 0; i < graphemes.Length; i++)
{
first = first < 0 ? i : first;
last = i;
FontRectangle advance = graphemes[i].Advance;
float start = isHorizontal ? advance.Left : advance.Top;
float end = isHorizontal ? advance.Right : advance.Bottom;
if (primary >= start && primary < end)
{
return i;
}
}
FontRectangle firstAdvance = graphemes[first].Advance;
float firstStart = isHorizontal ? firstAdvance.Left : firstAdvance.Top;
return primary < firstStart ? first : last;
}
///
/// Finds the adjacent visual line in the requested direction.
///
/// The visual lines among which an adjacent line is searched.
/// The current visual line index.
/// Specifies whether the search moves forward in visual order.
/// Indicates whether cross-axis distances are measured vertically.
/// The adjacent line index, or when no line exists in that direction.
private static int FindAdjacentLine(
ReadOnlySpan lines,
int lineIndex,
bool lineDown,
bool isHorizontal)
{
float currentStart = GetLineCrossStart(lines[lineIndex], isHorizontal);
float currentEnd = GetLineCrossEnd(lines[lineIndex], isHorizontal);
int targetLineIndex = lineIndex;
float bestDistance = float.MaxValue;
for (int i = 0; i < lines.Length; i++)
{
if (i == lineIndex)
{
continue;
}
float distance = lineDown
? GetLineCrossStart(lines[i], isHorizontal) - currentEnd
: currentStart - GetLineCrossEnd(lines[i], isHorizontal);
if (distance >= 0 && distance < bestDistance)
{
targetLineIndex = i;
bestDistance = distance;
}
}
return targetLineIndex;
}
///
/// Gets a valid line index for the supplied caret.
///
/// The laid-out lines used to validate the caret's stored line index.
/// The flattened grapheme metrics used to resolve the caret when its line index is stale.
/// The caret whose associated visual line must be resolved.
/// The line index.
private static int GetCaretLineIndex(
ReadOnlySpan lines,
ReadOnlySpan graphemes,
in CaretPosition caret)
{
if ((uint)caret.LineIndex < (uint)lines.Length)
{
return caret.LineIndex;
}
return FindLineByGraphemeIndex(lines, caret.GraphemeIndex);
}
///
/// Gets the nearest Unicode word boundary before the supplied grapheme insertion index.
///
/// The source-order word metrics to search.
/// The grapheme insertion index to move from.
/// The minimum grapheme insertion index that can be returned.
/// The previous word boundary.
private static int GetPreviousWordBoundary(
ReadOnlySpan wordMetrics,
int graphemeIndex,
int limit)
{
int target = limit;
for (int i = 0; i < wordMetrics.Length; i++)
{
WordMetrics metrics = wordMetrics[i];
if (metrics.GraphemeStart >= graphemeIndex)
{
break;
}
target = Math.Max(target, metrics.GraphemeStart);
if (metrics.GraphemeEnd < graphemeIndex)
{
target = Math.Max(target, metrics.GraphemeEnd);
}
}
return target;
}
///
/// Gets the nearest Unicode word boundary after the supplied grapheme insertion index.
///
/// The source-order word metrics to search.
/// The grapheme insertion index to move from.
/// The maximum grapheme insertion index that can be returned.
/// The next word boundary.
private static int GetNextWordBoundary(
ReadOnlySpan wordMetrics,
int graphemeIndex,
int limit)
{
for (int i = 0; i < wordMetrics.Length; i++)
{
WordMetrics metrics = wordMetrics[i];
if (metrics.GraphemeStart > graphemeIndex)
{
return Math.Min(limit, metrics.GraphemeStart);
}
if (metrics.GraphemeEnd > graphemeIndex)
{
return Math.Min(limit, metrics.GraphemeEnd);
}
}
return limit;
}
///
/// Gets the previous measured grapheme insertion index.
///
/// The grapheme metrics that define valid caret stops.
/// The caret insertion index to move from.
/// The minimum grapheme insertion index that can be returned.
/// The previous measured grapheme insertion index.
private static int GetPreviousInsertionIndex(
ReadOnlySpan graphemes,
int graphemeIndex,
int limit)
{
int target = limit;
for (int i = 0; i < graphemes.Length; i++)
{
int start = graphemes[i].GraphemeIndex;
if (start < graphemeIndex)
{
target = Math.Max(target, start);
}
// The trailing boundary is derived only from an actual measured grapheme.
// This avoids walking through sparse source indices left by trimmed text.
int end = start + 1;
if (end < graphemeIndex)
{
target = Math.Max(target, end);
}
}
return target;
}
///
/// Gets the next measured grapheme insertion index.
///
/// The grapheme metrics that define valid caret stops.
/// The caret insertion index to move from.
/// The maximum grapheme insertion index that can be returned.
/// The next measured grapheme insertion index.
private static int GetNextInsertionIndex(
ReadOnlySpan graphemes,
int graphemeIndex,
int limit)
{
int target = limit;
for (int i = 0; i < graphemes.Length; i++)
{
int start = graphemes[i].GraphemeIndex;
if (start > graphemeIndex)
{
target = Math.Min(target, start);
}
// The trailing boundary is derived only from an actual measured grapheme.
// This avoids walking through sparse source indices left by trimmed text.
int end = start + 1;
if (end > graphemeIndex)
{
target = Math.Min(target, end);
}
}
return target;
}
///
/// Gets the first source grapheme insertion index in the laid-out text.
///
/// The laid-out grapheme metrics searched for the earliest source insertion point.
/// The source text start insertion index.
private static int GetSourceTextStart(ReadOnlySpan graphemes)
{
int start = graphemes[0].GraphemeIndex;
for (int i = 1; i < graphemes.Length; i++)
{
start = Math.Min(start, graphemes[i].GraphemeIndex);
}
return start;
}
///
/// Gets the final source grapheme insertion index in the laid-out text.
///
/// The laid-out grapheme metrics searched for the final source insertion point.
/// The source text end insertion index.
private static int GetSourceTextEnd(ReadOnlySpan graphemes)
{
int end = graphemes[0].GraphemeIndex + 1;
for (int i = 1; i < graphemes.Length; i++)
{
end = Math.Max(end, graphemes[i].GraphemeIndex + 1);
}
return end;
}
///
/// Gets the first source grapheme insertion index for a line.
///
/// The line-local grapheme metrics.
/// The source line start insertion index.
private static int GetSourceLineStart(ReadOnlySpan graphemes)
{
int start = graphemes[0].GraphemeIndex;
for (int i = 1; i < graphemes.Length; i++)
{
start = Math.Min(start, graphemes[i].GraphemeIndex);
}
return start;
}
///
/// Gets the final source grapheme insertion index for a line.
///
/// The line-local grapheme metrics.
/// The source line end insertion index.
private static int GetSourceLineEnd(ReadOnlySpan graphemes)
{
int end = graphemes[0].GraphemeIndex + 1;
for (int i = 1; i < graphemes.Length; i++)
{
end = Math.Max(end, graphemes[i].GraphemeIndex + 1);
}
return end;
}
///
/// Gets the cross-axis start of a line.
///
/// The line whose cross-axis origin is requested.
/// Indicates whether the cross axis corresponds to y coordinates.
/// The cross-axis start.
private static float GetLineCrossStart(in LineMetrics line, bool isHorizontal)
=> isHorizontal ? line.Start.Y : line.Start.X;
///
/// Gets the cross-axis end of a line.
///
/// The line whose cross-axis limit is requested.
/// Indicates whether the cross axis corresponds to y coordinates.
/// The cross-axis end.
private static float GetLineCrossEnd(in LineMetrics line, bool isHorizontal)
=> isHorizontal ? line.Start.Y + line.Extent.Y : line.Start.X + line.Extent.X;
///
/// Gets the coordinate to preserve for repeated visual line movement.
///
/// The primary caret endpoint used to preserve visual column movement.
/// Indicates whether the preserved coordinate is taken from x.
/// The line navigation position.
private static float GetLineNavigationPosition(Vector2 start, bool isHorizontal)
=> isHorizontal ? start.X : start.Y;
///
/// Creates a copy of the caret with a specific preserved line navigation position.
///
/// The caret value to clone with updated navigation metadata.
/// The preserved visual column or row coordinate.
/// The caret position.
private static CaretPosition WithLineNavigationPosition(
in CaretPosition caret,
float lineNavigationPosition)
=> new(
caret.LineIndex,
caret.GraphemeIndex,
caret.StringIndex,
caret.Start,
caret.End,
caret.HasSecondary,
caret.SecondaryStart,
caret.SecondaryEnd,
lineNavigationPosition);
///
/// Fills one line's selection rectangles from visually contiguous selected grapheme advances.
///
/// The line that will receive one or more selection rectangles.
/// The line-local grapheme metrics grouped into visual runs.
/// The first source grapheme insertion boundary in the selected range.
/// The final source grapheme insertion boundary in the selected range.
/// Indicates whether rectangles expand primarily along x.
/// The destination span that receives the generated rectangles.
/// The number of selection rectangles written.
private static int FillSelectionBoundsLine(
in LineMetrics line,
ReadOnlySpan graphemes,
int selectionStart,
int selectionEnd,
bool isHorizontal,
Span result)
{
int count = 0;
bool hasSelection = false;
float start = 0;
float end = 0;
for (int i = 0; i < graphemes.Length; i++)
{
GraphemeMetrics grapheme = graphemes[i];
// Selections are caret boundary ranges: [start, end). A grapheme is selected
// when its source start sits inside that boundary span.
int graphemeStart = grapheme.GraphemeIndex;
bool isSelected = graphemeStart >= selectionStart && graphemeStart < selectionEnd;
if (!isSelected)
{
// A logical range can be visually discontinuous after bidi reordering. Flush at
// the first unselected visual grapheme so selection never covers that gap.
if (hasSelection)
{
result[count++] = CreateSelectionBounds(line, start, end, isHorizontal);
hasSelection = false;
}
continue;
}
FontRectangle advance = grapheme.Advance;
float currentStart = isHorizontal ? advance.Left : advance.Top;
float currentEnd = isHorizontal ? advance.Right : advance.Bottom;
if (!hasSelection)
{
start = currentStart;
end = currentEnd;
hasSelection = true;
continue;
}
start = Math.Min(start, currentStart);
end = Math.Max(end, currentEnd);
}
if (hasSelection)
{
result[count++] = CreateSelectionBounds(line, start, end, isHorizontal);
}
return count;
}
///
/// Creates a selection rectangle for a contiguous visual run.
///
/// The containing line used to fill the rectangle on the secondary axis.
/// The first selected coordinate along the primary layout axis.
/// The last selected coordinate along the primary layout axis.
/// Indicates whether the primary axis runs left to right.
/// The selection rectangle in pixel units.
private static FontRectangle CreateSelectionBounds(
in LineMetrics line,
float start,
float end,
bool isHorizontal)
=>
isHorizontal
? FontRectangle.FromLTRB(start, line.Start.Y, end, line.Start.Y + line.Extent.Y)
: FontRectangle.FromLTRB(line.Start.X, start, line.Start.X + line.Extent.X, end);
///
/// Creates a selection rectangle for one measured grapheme.
///
/// The containing line used to fill the rectangle on the secondary axis.
/// The grapheme whose advance defines the primary-axis selection extent.
/// Indicates whether the primary axis runs left to right.
/// The selection rectangle in pixel units.
private static FontRectangle CreateSelectionBounds(
in LineMetrics line,
in GraphemeMetrics grapheme,
bool isHorizontal)
{
FontRectangle advance = grapheme.Advance;
float start = isHorizontal ? advance.Left : advance.Top;
float end = isHorizontal ? advance.Right : advance.Bottom;
return CreateSelectionBounds(line, start, end, isHorizontal);
}
///
/// Counts how many selection rectangles are required for a grapheme range.
///
/// The visual lines searched for selected graphemes.
/// The flattened grapheme metrics used to count visual runs.
/// The first source grapheme insertion boundary used for counting.
/// The final source grapheme insertion boundary used for counting.
/// The number of selection rectangles.
private static int CountSelectionBounds(
ReadOnlySpan lines,
ReadOnlySpan graphemes,
int selectionStart,
int selectionEnd)
{
int count = 0;
for (int i = 0; i < lines.Length; i++)
{
LineMetrics line = lines[i];
int graphemeOffset = GetGraphemeOffset(line);
ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, line.GraphemeCount);
// Source grapheme indices can have gaps because trailing whitespace is trimmed.
// Count actual measured graphemes instead of deriving a dense range from the line.
count += CountSelectionBoundsLine(lineGraphemes, selectionStart, selectionEnd);
}
return count;
}
///
/// Counts visually contiguous selected grapheme runs in one line.
///
/// The visual-order grapheme metrics for the current line.
/// The first source grapheme insertion boundary applied to that line.
/// The final source grapheme insertion boundary applied to that line.
/// The number of selected visual runs.
private static int CountSelectionBoundsLine(
ReadOnlySpan graphemes,
int selectionStart,
int selectionEnd)
{
int count = 0;
bool hasSelection = false;
for (int i = 0; i < graphemes.Length; i++)
{
GraphemeMetrics grapheme = graphemes[i];
// Selections are caret boundary ranges: [start, end). A grapheme is selected
// when its source start sits inside that boundary span.
int graphemeStart = grapheme.GraphemeIndex;
bool isSelected = graphemeStart >= selectionStart && graphemeStart < selectionEnd;
if (!isSelected)
{
hasSelection = false;
continue;
}
if (!hasSelection)
{
count++;
hasSelection = true;
}
}
return count;
}
///
/// Finds the grapheme whose advance contains the primary coordinate, or the nearest edge grapheme.
///
/// The visual-order grapheme metrics searched for a hit target.
/// The coordinate along the primary layout axis.
/// Indicates whether the primary axis is horizontal.
/// The nearest grapheme metrics index within .
private static int FindNearestGrapheme(ReadOnlySpan graphemes, float primary, bool isHorizontal)
{
for (int i = 0; i < graphemes.Length; i++)
{
FontRectangle advance = graphemes[i].Advance;
float start = isHorizontal ? advance.Left : advance.Top;
float end = isHorizontal ? advance.Right : advance.Bottom;
if (primary >= start && primary < end)
{
return i;
}
}
FontRectangle first = graphemes[0].Advance;
float firstStart = isHorizontal ? first.Left : first.Top;
return primary < firstStart ? 0 : graphemes.Length - 1;
}
///
/// Finds the metrics entry for a source grapheme index within one visual line.
///
/// The visual-order grapheme metrics belonging to one line.
/// The logical grapheme index to look up directly.
/// The grapheme metrics index, or -1 when the grapheme is not in the line.
private static int FindGraphemeBySourceIndex(ReadOnlySpan graphemes, int graphemeIndex)
{
for (int i = 0; i < graphemes.Length; i++)
{
if (graphemes[i].GraphemeIndex == graphemeIndex)
{
return i;
}
}
return -1;
}
///
/// Finds the nearest metrics entry for a source grapheme index within one visual line.
///
/// The visual-order grapheme metrics used for nearest-index matching.
/// The logical grapheme index whose closest visual entry is needed.
/// The nearest grapheme metrics index within .
private static int FindNearestGraphemeIndex(ReadOnlySpan graphemes, int graphemeIndex)
{
int nearest = 0;
int distance = Math.Abs(graphemes[0].GraphemeIndex - graphemeIndex);
for (int i = 1; i < graphemes.Length; i++)
{
int currentDistance = Math.Abs(graphemes[i].GraphemeIndex - graphemeIndex);
if (currentDistance < distance)
{
nearest = i;
distance = currentDistance;
}
}
return nearest;
}
///
/// Gets a value indicating whether the grapheme advances right-to-left in source order.
///
/// The grapheme whose resolved bidi level is inspected.
/// when the resolved bidi level is odd.
private static bool IsRightToLeft(in GraphemeMetrics grapheme)
=> (grapheme.BidiLevel & 1) != 0;
///
/// Gets the offset of a line's graphemes within the flattened metrics array.
///
/// The line whose stored grapheme offset identifies the desired slice.
/// The flattened grapheme metrics offset.
private static int GetGraphemeOffset(in LineMetrics line)
=> line.GraphemeOffset;
}
}