// 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; } }