diff --git a/lib/ui/dart_ui.cc b/lib/ui/dart_ui.cc index 6b048b4af3596..f2c0c7d3d7fd9 100644 --- a/lib/ui/dart_ui.cc +++ b/lib/ui/dart_ui.cc @@ -217,6 +217,8 @@ typedef CanvasPath Path; V(Paragraph, computeLineMetrics, 1) \ V(Paragraph, didExceedMaxLines, 1) \ V(Paragraph, dispose, 1) \ + V(Paragraph, getClosestGlyphInfo, 3) \ + V(Paragraph, getGlyphInfoAt, 2) \ V(Paragraph, getLineBoundary, 2) \ V(Paragraph, getLineMetricsAt, 3) \ V(Paragraph, getLineNumberAt, 2) \ diff --git a/lib/ui/text.dart b/lib/ui/text.dart index 276c326106dc3..374011ea45c66 100644 --- a/lib/ui/text.dart +++ b/lib/ui/text.dart @@ -1191,6 +1191,54 @@ class FontVariation { String toString() => "FontVariation('$axis', $value)"; } +/// The measurements of a character (or a sequence of visually connected +/// characters) within a paragraph. +/// +/// See also: +/// +/// * [Paragraph.getGlyphInfoAt], which finds the [GlyphInfo] associated with +/// a code unit in the text. +/// * [Paragraph.getClosestGlyphInfoForOffset], which finds the [GlyphInfo] of +/// the glyph(s) onscreen that's closest to the given [Offset]. +final class GlyphInfo { + GlyphInfo._(double left, double top, double right, double bottom, int graphemeStart, int graphemeEnd, bool isLTR) + : graphemeClusterLayoutBounds = Rect.fromLTRB(left, top, right, bottom), + graphemeClusterCodeUnitRange = TextRange(start: graphemeStart, end: graphemeEnd), + writingDirection = isLTR ? TextDirection.ltr : TextDirection.rtl; + + /// The layout bounding rect of the associated character, in the paragraph's + /// coordinates. + /// + /// This is **not** the tight bounding box that encloses the character's outline. + /// The vertical extent reported is derived from the font metrics (instead of + /// glyph metrics), and the horizontal extent is the horizontal advance of the + /// character. + final Rect graphemeClusterLayoutBounds; + + /// The UTF-16 range of the associated character in the text. + final TextRange graphemeClusterCodeUnitRange; + + /// The writing direction within the [GlyphInfo]. + final TextDirection writingDirection; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is GlyphInfo + && graphemeClusterLayoutBounds == other.graphemeClusterLayoutBounds + && graphemeClusterCodeUnitRange == other.graphemeClusterCodeUnitRange + && writingDirection == other.writingDirection; + } + + @override + int get hashCode => Object.hash(graphemeClusterLayoutBounds, graphemeClusterCodeUnitRange, writingDirection); + + @override + String toString() => 'Glyph($graphemeClusterLayoutBounds, textRange: $graphemeClusterCodeUnitRange, direction: $writingDirection)'; +} + /// Whether and how to align text horizontally. // The order of this enum must match the order of the values in RenderStyleConstants.h's ETextAlign. enum TextAlign { @@ -2971,6 +3019,23 @@ abstract class Paragraph { /// Returns the text position closest to the given offset. TextPosition getPositionForOffset(Offset offset); + /// Returns the [GlyphInfo] of the glyph closest to the given `offset` in the + /// paragraph coordinate system, or null if the glyph is not in the visible + /// range. + /// + /// This method first finds the line closest to `offset.dy`, and then returns + /// the [GlyphInfo] of the closest glyph(s) within that line. + /// + /// This method can be used to implement per-glyph hit-testing. The returned + /// [GlyphInfo] can help determine whether the given `offset` directly hits a + /// glyph in the paragraph. + GlyphInfo? getClosestGlyphInfoForOffset(Offset offset); + + /// Returns the [GlyphInfo] located at the given UTF-16 `codeUnitOffset` in + /// the paragraph, or null if the given `codeUnitOffset` is out of the visible + /// lines or is ellipsized. + GlyphInfo? getGlyphInfoAt(int codeUnitOffset); + /// Returns the [TextRange] of the word at the given [TextPosition]. /// /// Characters not part of a word, such as spaces, symbols, and punctuation, @@ -3135,6 +3200,16 @@ base class _NativeParagraph extends NativeFieldWrapperClass1 implements Paragrap @Native, Double, Double)>(symbol: 'Paragraph::getPositionForOffset') external List _getPositionForOffset(double dx, double dy); + @override + GlyphInfo? getGlyphInfoAt(int codeUnitOffset) => _getGlyphInfoAt(codeUnitOffset, GlyphInfo._); + @Native, Uint32, Handle)>(symbol: 'Paragraph::getGlyphInfoAt') + external GlyphInfo? _getGlyphInfoAt(int codeUnitOffset, Function constructor); + + @override + GlyphInfo? getClosestGlyphInfoForOffset(Offset offset) => _getClosestGlyphInfoForOffset(offset.dx, offset.dy, GlyphInfo._); + @Native, Double, Double, Handle)>(symbol: 'Paragraph::getClosestGlyphInfo') + external GlyphInfo? _getClosestGlyphInfoForOffset(double dx, double dy, Function constructor); + @override TextRange getWordBoundary(TextPosition position) { final int characterPosition; diff --git a/lib/ui/text/paragraph.cc b/lib/ui/text/paragraph.cc index 8c4cd1f1fc35a..509c6e586edd5 100644 --- a/lib/ui/text/paragraph.cc +++ b/lib/ui/text/paragraph.cc @@ -120,8 +120,51 @@ Dart_Handle Paragraph::getPositionForOffset(double dx, double dy) { return tonic::DartConverter::ToDart(result); } -Dart_Handle Paragraph::getWordBoundary(unsigned offset) { - txt::Paragraph::Range point = m_paragraph_->GetWordBoundary(offset); +Dart_Handle glyphInfoFrom( + Dart_Handle constructor, + const skia::textlayout::Paragraph::GlyphInfo& glyphInfo) { + std::array arguments = { + Dart_NewDouble(glyphInfo.fGraphemeLayoutBounds.fLeft), + Dart_NewDouble(glyphInfo.fGraphemeLayoutBounds.fTop), + Dart_NewDouble(glyphInfo.fGraphemeLayoutBounds.fRight), + Dart_NewDouble(glyphInfo.fGraphemeLayoutBounds.fBottom), + Dart_NewInteger(glyphInfo.fGraphemeClusterTextRange.start), + Dart_NewInteger(glyphInfo.fGraphemeClusterTextRange.end), + Dart_NewBoolean(glyphInfo.fDirection == + skia::textlayout::TextDirection::kLtr), + }; + return Dart_InvokeClosure(constructor, arguments.size(), arguments.data()); +} + +Dart_Handle Paragraph::getGlyphInfoAt(unsigned utf16Offset, + Dart_Handle constructor) const { + skia::textlayout::Paragraph::GlyphInfo glyphInfo; + const bool found = m_paragraph_->GetGlyphInfoAt(utf16Offset, &glyphInfo); + if (!found) { + return Dart_Null(); + } + Dart_Handle handle = glyphInfoFrom(constructor, glyphInfo); + tonic::CheckAndHandleError(handle); + return handle; +} + +Dart_Handle Paragraph::getClosestGlyphInfo(double dx, + double dy, + Dart_Handle constructor) const { + skia::textlayout::Paragraph::GlyphInfo glyphInfo; + const bool found = + m_paragraph_->GetClosestGlyphInfoAtCoordinate(dx, dy, &glyphInfo); + if (!found) { + return Dart_Null(); + } + Dart_Handle handle = glyphInfoFrom(constructor, glyphInfo); + tonic::CheckAndHandleError(handle); + return handle; +} + +Dart_Handle Paragraph::getWordBoundary(unsigned utf16Offset) { + txt::Paragraph::Range point = + m_paragraph_->GetWordBoundary(utf16Offset); std::vector result = {point.start, point.end}; return tonic::DartConverter::ToDart(result); } diff --git a/lib/ui/text/paragraph.h b/lib/ui/text/paragraph.h index f47a20a5c046d..a1f4c96eb49e5 100644 --- a/lib/ui/text/paragraph.h +++ b/lib/ui/text/paragraph.h @@ -44,6 +44,11 @@ class Paragraph : public RefCountedDartWrappable { unsigned boxWidthStyle); tonic::Float32List getRectsForPlaceholders(); Dart_Handle getPositionForOffset(double dx, double dy); + Dart_Handle getGlyphInfoAt(unsigned utf16Offset, + Dart_Handle constructor) const; + Dart_Handle getClosestGlyphInfo(double dx, + double dy, + Dart_Handle constructor) const; Dart_Handle getWordBoundary(unsigned offset); Dart_Handle getLineBoundary(unsigned offset); tonic::Float64List computeLineMetrics() const; diff --git a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart index 228937963f434..fea055f16bbec 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart @@ -3193,6 +3193,29 @@ extension SkLineMetricsExtension on SkLineMetrics { double get lineNumber => _lineNumber.toDartDouble; } +@JS() +@anonymous +@staticInterop +class SkGlyphClusterInfo {} + +extension SkGlyphClusterInfoExtension on SkGlyphClusterInfo { + @JS('graphemeLayoutBounds') + external JSArray get _bounds; + + @JS('dir') + external SkTextDirection get _direction; + + @JS('graphemeClusterTextRange') + external SkTextRange get _textRange; + + ui.GlyphInfo get _glyphInfo { + final List list = _bounds.toDart.cast(); + final ui.Rect bounds = ui.Rect.fromLTRB(list[0].toDartDouble, list[1].toDartDouble, list[2].toDartDouble, list[3].toDartDouble); + final ui.TextRange textRange = ui.TextRange(start: _textRange.start.toInt(), end: _textRange.end.toInt()); + return ui.GlyphInfo(bounds, textRange, ui.TextDirection.values[_direction.value.toInt()]); + } +} + @JS() @anonymous @staticInterop @@ -3295,6 +3318,14 @@ extension SkParagraphExtension on SkParagraph { double y, ) => _getGlyphPositionAtCoordinate(x.toJS, y.toJS); + @JS('getGlyphInfoAt') + external SkGlyphClusterInfo? _getGlyphInfoAt(JSNumber position); + ui.GlyphInfo? getGlyphInfoAt(double position) => _getGlyphInfoAt(position.toJS)?._glyphInfo; + + @JS('getClosestGlyphInfoAtCoordinate') + external SkGlyphClusterInfo? _getClosestGlyphInfoAtCoordinate(JSNumber x, JSNumber y); + ui.GlyphInfo? getClosestGlyphInfoAt(double x, double y) => _getClosestGlyphInfoAtCoordinate(x.toJS, y.toJS)?._glyphInfo; + @JS('getWordBoundary') external SkTextRange _getWordBoundary(JSNumber position); SkTextRange getWordBoundary(double position) => diff --git a/lib/web_ui/lib/src/engine/canvaskit/text.dart b/lib/web_ui/lib/src/engine/canvaskit/text.dart index 6284ffe55bd37..cc52f41d33526 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/text.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/text.dart @@ -658,6 +658,18 @@ class CkParagraph implements ui.Paragraph { return fromPositionWithAffinity(positionWithAffinity); } + @override + ui.GlyphInfo? getClosestGlyphInfoForOffset(ui.Offset offset) { + assert(!_disposed, 'Paragraph has been disposed.'); + return skiaObject.getClosestGlyphInfoAt(offset.dx, offset.dy); + } + + @override + ui.GlyphInfo? getGlyphInfoAt(int codeUnitOffset) { + assert(!_disposed, 'Paragraph has been disposed.'); + return skiaObject.getGlyphInfoAt(codeUnitOffset.toDouble()); + } + @override ui.TextRange getWordBoundary(ui.TextPosition position) { assert(!_disposed, 'Paragraph has been disposed.'); diff --git a/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart b/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart index af721f72b2864..02dc5cc274944 100644 --- a/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart +++ b/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart @@ -188,6 +188,38 @@ class SkwasmParagraph extends SkwasmObjectWrapper implements ui.Pa ); }); + @override + ui.GlyphInfo? getGlyphInfoAt(int codeUnitOffset) { + return withStackScope((StackScope scope) { + final Pointer outRect = scope.allocFloatArray(4); + final Pointer outRange = scope.allocUint32Array(2); + final Pointer outBooleanFlags = scope.allocBoolArray(1); + return paragraphGetGlyphInfoAt(handle, codeUnitOffset, outRect, outRange, outBooleanFlags) + ? ui.GlyphInfo( + scope.convertRectFromNative(outRect), + ui.TextRange(start: outRange[0], end: outRange[1]), + outBooleanFlags[0] ? ui.TextDirection.ltr : ui.TextDirection.rtl, + ) + : null; + }); + } + + @override + ui.GlyphInfo? getClosestGlyphInfoForOffset(ui.Offset offset) { + return withStackScope((StackScope scope) { + final Pointer outRect = scope.allocFloatArray(4); + final Pointer outRange = scope.allocUint32Array(2); + final Pointer outBooleanFlags = scope.allocBoolArray(1); + return paragraphGetClosestGlyphInfoAtCoordinate(handle, offset.dx, offset.dy, outRect, outRange, outBooleanFlags) + ? ui.GlyphInfo( + scope.convertRectFromNative(outRect), + ui.TextRange(start: outRange[0], end: outRange[1]), + outBooleanFlags[0] ? ui.TextDirection.ltr : ui.TextDirection.rtl, + ) + : null; + }); + } + @override ui.TextRange getWordBoundary(ui.TextPosition position) => withStackScope((StackScope scope) { final Pointer outRange = scope.allocInt32Array(2); diff --git a/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_memory.dart b/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_memory.dart index 90ff2b43ed4d0..9b63df3e12439 100644 --- a/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_memory.dart +++ b/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_memory.dart @@ -196,6 +196,11 @@ class StackScope { return pointer; } + Pointer allocBoolArray(int count) { + final int length = count * sizeOf(); + return stackAlloc(length).cast(); + } + Pointer allocInt8Array(int count) { final int length = count * sizeOf(); return stackAlloc(length).cast(); diff --git a/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/text/raw_paragraph.dart b/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/text/raw_paragraph.dart index 6be8ea8a9014c..f8a3e811719a7 100644 --- a/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/text/raw_paragraph.dart +++ b/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/text/raw_paragraph.dart @@ -72,6 +72,24 @@ external int paragraphGetPositionForOffset( Pointer outAffinity, ); +@Native, Pointer)>(symbol: 'paragraph_getClosestGlyphInfoAtCoordinate') +external bool paragraphGetClosestGlyphInfoAtCoordinate( + ParagraphHandle handle, + double offsetX, double offsetY, + RawRect graphemeLayoutBounds, // 4 floats, [LTRB] + Pointer graphemeCodeUnitRange, // 2 `size_t`s, start and end. + Pointer booleanFlags, // 1 boolean, isLTR. +); + +@Native, Pointer)>(symbol: 'paragraph_getGlyphInfoAt') +external bool paragraphGetGlyphInfoAt( + ParagraphHandle handle, + int codeUnitOffset, + RawRect graphemeLayoutBounds, // 4 floats, [LTRB] + Pointer graphemeCodeUnitRange, // 2 `size_t`s, start and end. + Pointer booleanFlags, // 1 boolean, isLTR. +); + @Native _layoutService.getClosestGlyphInfo(offset); + + @override + ui.GlyphInfo? getGlyphInfoAt(int codeUnitOffset) { + final int? lineNumber = _findLine(codeUnitOffset, 0, numberOfLines); + if (lineNumber == null) { + return null; + } + final ParagraphLine line = lines[lineNumber]; + final ui.TextRange? range = line.getCharacterRangeAt(codeUnitOffset); + if (range == null) { + return null; + } + assert(line.overlapsWith(range.start, range.end)); + for (final LayoutFragment fragment in line.fragments) { + if (fragment.overlapsWith(range.start, range.end)) { + // If the grapheme cluster is split into multiple fragments (which really + // shouldn't happen but currently if they are in different TextSpans they + // don't combine), use the layout box of the first base character as its + // layout box has a better chance to be not that far-off. + final ui.TextBox textBox = fragment.toTextBox(start: range.start, end: range.end); + return ui.GlyphInfo(textBox.toRect(), range, textBox.direction); + } + } + assert(false, 'This should not be reachable.'); + return null; + } + @override ui.TextRange getWordBoundary(ui.TextPosition position) { final int characterPosition; @@ -249,11 +278,18 @@ class CanvasParagraph implements ui.Paragraph { int? getLineNumberAt(int codeUnitOffset) => _findLine(codeUnitOffset, 0, lines.length); int? _findLine(int codeUnitOffset, int startLine, int endLine) { - if (endLine <= startLine || codeUnitOffset < lines[startLine].startIndex || lines[endLine - 1].endIndex <= codeUnitOffset) { + assert(endLine <= lines.length); + final bool isOutOfBounds = endLine <= startLine + || codeUnitOffset < lines[startLine].startIndex + || (endLine < numberOfLines && lines[endLine].startIndex <= codeUnitOffset); + if (isOutOfBounds) { return null; } + if (endLine == startLine + 1) { - return startLine; + assert(lines[startLine].startIndex <= codeUnitOffset); + assert(endLine == numberOfLines || codeUnitOffset < lines[endLine].startIndex); + return codeUnitOffset >= lines[startLine].visibleEndIndex ? null : startLine; } // endLine >= startLine + 2 thus we have // startLine + 1 <= midIndex <= endLine - 1 diff --git a/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart b/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart index 0c48c0f5eeb92..cf1eb647c2103 100644 --- a/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart +++ b/lib/web_ui/lib/src/engine/text/layout_fragmenter.dart @@ -591,6 +591,107 @@ mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition { } return x; } + + // [start, end).map((index) => line.graphemeStarts[index]) gives an ascending + // list of UTF16 offsets of graphemes that start in this fragment. + // + // Returns null if this fragment contains no grapheme starts. + late final (int, int)? graphemeStartIndexRange = _getBreaksRange(); + (int, int)? _getBreaksRange() { + if (end == start) { + return null; + } + final List lineGraphemeBreaks = line.graphemeStarts; + assert(end > start); + assert(line.graphemeStarts.isNotEmpty); + final int startIndex = line.graphemeStartIndexBefore(start, 0, lineGraphemeBreaks.length); + final int endIndex = end == start + 1 + ? startIndex + 1 + : line.graphemeStartIndexBefore(end - 1, startIndex, lineGraphemeBreaks.length) + 1; + final int firstGraphemeStart = lineGraphemeBreaks[startIndex]; + return firstGraphemeStart > start + ? (endIndex == startIndex + 1 ? null : (startIndex + 1, endIndex)) + : (startIndex, endIndex); + } + + /// Whether the first codepoints of this fragment is not a valid grapheme start, + /// and belongs in the the previous fragment. + /// + /// This is the result of a known bug: in rare circumstances, a grapheme is + /// split into different fragments. To workaround this we ignore the trailing + /// part of the grapheme during hit-testing, by adjusting the leading offset of + /// a fragment to the leading edge of the first grapheme start in that fragment. + // + // TODO(LongCatIsLooong): Grapheme clusters should not be separately even + // when they are in different runs. Also document the recommendation to use + // U+25CC or U+00A0 for showing nonspacing marks in isolation. + bool get hasLeadingBrokenGrapheme { + final int? graphemeStartIndexRangeStart = graphemeStartIndexRange?.$1; + return graphemeStartIndexRangeStart == null || line.graphemeStarts[graphemeStartIndexRangeStart] != start; + } + + /// Returns the GlyphInfo within the range [line.graphemeStarts[startIndex], line.graphemeStarts[endIndex]), + /// that's visually closeset to the given horizontal offset `x` (in the paragraph's coordinates). + ui.GlyphInfo _getClosestCharacterInRange(double x, int startIndex, int endIndex) { + final List graphemeStartIndices = line.graphemeStarts; + final ui.TextRange fullRange = ui.TextRange(start: graphemeStartIndices[startIndex], end: graphemeStartIndices[endIndex]); + final ui.TextBox fullBox = toTextBox(start: fullRange.start, end: fullRange.end); + if (startIndex + 1 == endIndex) { + return ui.GlyphInfo(fullBox.toRect(), fullRange, fullBox.direction); + } + assert(startIndex + 1 < endIndex); + final ui.TextBox(:double left, :double right) = fullBox; + + // The toTextBox call is potentially expensive so we'll try reducing the + // search steps with a binary search. + // + // x ∈ (left, right), + if (left < x && x < right) { + final int midIndex = (startIndex + endIndex) ~/ 2; + // endIndex >= startIndex + 2, so midIndex >= start + 1 + final ui.GlyphInfo firstHalf = _getClosestCharacterInRange(x, startIndex, midIndex); + if (firstHalf.graphemeClusterLayoutBounds.left < x && x < firstHalf.graphemeClusterLayoutBounds.right) { + return firstHalf; + } + // startIndex <= endIndex - 2, so midIndex <= endIndex - 1 + final ui.GlyphInfo secondHalf = _getClosestCharacterInRange(x, midIndex, endIndex); + if (secondHalf.graphemeClusterLayoutBounds.left < x && x < secondHalf.graphemeClusterLayoutBounds.right) { + return secondHalf; + } + // Neither box clips the given x. This is supposed to be rare. + final double distanceToFirst = (x - x.clamp(firstHalf.graphemeClusterLayoutBounds.left, firstHalf.graphemeClusterLayoutBounds.right)).abs(); + final double distanceToSecond = (x - x.clamp(secondHalf.graphemeClusterLayoutBounds.left, secondHalf.graphemeClusterLayoutBounds.right)).abs(); + return distanceToFirst > distanceToSecond ? firstHalf : secondHalf; + } + + // x ∉ (left, right), it's either the first character or the last, since + // there can only be one writing direction in the fragment. + final ui.TextRange range = switch ((fullBox.direction, x <= left)) { + (ui.TextDirection.ltr, true) || (ui.TextDirection.rtl, false) => ui.TextRange( + start: graphemeStartIndices[startIndex], + end: graphemeStartIndices[startIndex + 1], + ), + (ui.TextDirection.ltr, false) || (ui.TextDirection.rtl, true) => ui.TextRange( + start: graphemeStartIndices[endIndex - 1], + end: graphemeStartIndices[endIndex], + ), + }; + assert(!range.isCollapsed); + final ui.TextBox box = toTextBox(start: range.start, end: range.end); + return ui.GlyphInfo(box.toRect(), range, box.direction); + } + + /// Returns the GlyphInfo of the character in the fragment that is closest to + /// the given offset x. + ui.GlyphInfo getClosestCharacterBox(double x) { + assert(end > start); + assert(graphemeStartIndexRange != null); + // The force ! is safe here because this method is only called by + // LayoutService.getClosestGlyphInfo which checks this fragment has at least + // one grapheme start before calling this method. + final (int rangeStart, int rangeEnd) = graphemeStartIndexRange!; + return _getClosestCharacterInRange(x, rangeStart, rangeEnd); + } } class EllipsisFragment extends LayoutFragment { diff --git a/lib/web_ui/lib/src/engine/text/layout_service.dart b/lib/web_ui/lib/src/engine/text/layout_service.dart index d03a70b91883d..cbaf0456ef764 100644 --- a/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -419,6 +419,45 @@ class TextLayoutService { return ui.TextPosition(offset: line.startIndex); } + ui.GlyphInfo? getClosestGlyphInfo(ui.Offset offset) { + final LayoutFragment? fragment = _findLineForY(offset.dy) + ?.closestFragmentAtOffset(offset.dx); + if (fragment == null) { + return null; + } + final double dx = offset.dx; + final bool closestGraphemeStartInFragment = !fragment.hasLeadingBrokenGrapheme + || dx <= fragment.line.left + || fragment.line.left + fragment.line.width <= dx + || switch (fragment.textDirection!) { + // If dx is closer to the trailing edge, no need to check other fragments. + ui.TextDirection.ltr => dx >= (fragment.left + fragment.right) / 2, + ui.TextDirection.rtl => dx <= (fragment.left + fragment.right) / 2, + }; + final ui.GlyphInfo candidate1 = fragment.getClosestCharacterBox(dx); + if (closestGraphemeStartInFragment) { + return candidate1; + } + final bool searchLeft = switch (fragment.textDirection!) { + ui.TextDirection.ltr => true, + ui.TextDirection.rtl => false, + }; + final ui.GlyphInfo? candidate2 = fragment.line.closestFragmentTo(fragment, searchLeft)?.getClosestCharacterBox(dx); + if (candidate2 == null) { + return candidate1; + } + + final double distance1 = math.min( + (candidate1.graphemeClusterLayoutBounds.left - dx).abs(), + (candidate1.graphemeClusterLayoutBounds.right - dx).abs(), + ); + final double distance2 = math.min( + (candidate2.graphemeClusterLayoutBounds.left - dx).abs(), + (candidate2.graphemeClusterLayoutBounds.right - dx).abs(), + ); + return distance2 > distance1 ? candidate1 : candidate2; + } + ParagraphLine? _findLineForY(double y) { if (lines.isEmpty) { return null; @@ -856,6 +895,7 @@ class LineBuilder { descent: descent, fragments: _fragments, textDirection: _paragraphDirection, + paragraph: paragraph, ); for (final LayoutFragment fragment in _fragments) { diff --git a/lib/web_ui/lib/src/engine/text/paragraph.dart b/lib/web_ui/lib/src/engine/text/paragraph.dart index 48ad6ac2bdec6..c14bbe01deb7b 100644 --- a/lib/web_ui/lib/src/engine/text/paragraph.dart +++ b/lib/web_ui/lib/src/engine/text/paragraph.dart @@ -125,6 +125,7 @@ class ParagraphLine { required this.widthWithTrailingSpaces, required this.fragments, required this.textDirection, + required this.paragraph, this.displayText, }) : assert(trailingNewlines <= endIndex - startIndex), lineMetrics = EngineLineMetrics( @@ -151,6 +152,17 @@ class ParagraphLine { /// the text and doesn't stop at the overflow cutoff. final int endIndex; + /// The largest visible index (exclusive) in this line. + /// + /// When the line contains an overflow, or is ellipsized at the end, this is + /// the largest index that remains visible in this line. If the entire line is + /// ellipsized, this returns [startIndex]; + late final int visibleEndIndex = switch (fragments) { + [] => startIndex, + [...final List rest, EllipsisFragment()] + || final List rest => rest.last.end, + }; + /// The number of new line characters at the end of the line. final int trailingNewlines; @@ -173,6 +185,10 @@ class ParagraphLine { final double widthWithTrailingSpaces; /// The fragments that make up this line. + /// + /// The fragments in the [List] are sorted by their logical order in within the + /// line. In other words, a [LayoutFragment] in the [List] will have larger + /// start and end indices than all [LayoutFragment]s that appear before it. final List fragments; /// The text direction of this line, which is the same as the paragraph's. @@ -181,6 +197,9 @@ class ParagraphLine { /// The text to be rendered on the screen representing this line. final String? displayText; + /// The [CanvasParagraph] this line is part of. + final CanvasParagraph paragraph; + /// The number of space characters in the line excluding trailing spaces. int get nonTrailingSpaces => spaceCount - trailingSpaces; @@ -208,6 +227,165 @@ class ParagraphLine { return buffer.toString(); } + // This is the fallback graphme breaker that is only used if Intl.Segmenter() + // is not supported so _fromDomSegmenter can't be called. This implementation + // breaks the text into UTF-16 codepoints instead of graphme clusters. + List _fallbackGraphemeStartIterable(String lineText) { + final List graphemeStarts = []; + bool precededByHighSurrogate = false; + for (int i = 0; i < lineText.length; i++) { + final int maskedCodeUnit = lineText.codeUnitAt(i) & 0xFC00; + // Only skip `i` if it points to a low surrogate in a valid surrogate pair. + if (maskedCodeUnit != 0xDC00 || !precededByHighSurrogate) { + graphemeStarts.add(startIndex + i); + } + precededByHighSurrogate = maskedCodeUnit == 0xD800; + } + return graphemeStarts; + } + + // This will be called at most once to lazily populate _graphemeStarts. + List _fromDomSegmenter(String fragmentText) { + final DomSegmenter domSegmenter = createIntlSegmenter(granularity: 'grapheme'); + final List graphemeStarts = []; + final Iterator segments = domSegmenter.segment(fragmentText).iterator(); + while (segments.moveNext()) { + graphemeStarts.add(segments.current.index + startIndex); + } + assert(graphemeStarts.isEmpty || graphemeStarts.first == startIndex); + return graphemeStarts; + } + + List _breakTextIntoGraphemes(String text) { + final List graphemeStarts = domIntl.Segmenter == null ? _fallbackGraphemeStartIterable(text) : _fromDomSegmenter(text); + // Add the end index of the fragment to the list if the text is not empty. + if (graphemeStarts.isNotEmpty) { + graphemeStarts.add(visibleEndIndex); + } + return graphemeStarts; + } + + /// This List contains an ascending sequence of UTF16 offsets that points to + /// grapheme starts within the line. Each UTF16 offset is relative to the + /// start of the paragraph, instead of the start of the line. + /// + /// For example, `graphemeStarts[n]` gives the UTF16 offset of the `n`-th + /// grapheme in the line. + late final List graphemeStarts = visibleEndIndex == startIndex + ? const [] + : _breakTextIntoGraphemes(paragraph.plainText.substring(startIndex, visibleEndIndex)); + + /// Translate a UTF16 code unit in the paragaph (`offset`), to a grapheme + /// offset with in the current line. + /// + /// The `start` and `end` parameters are both grapheme offsets within the + /// current line. They are used to limit the search range (so the return value + /// that corresponds to the code unit `offset` must be with in [start, end)). + int graphemeStartIndexBefore(int offset, int start, int end) { + int low = start; + int high = end; + assert(0 <= low); + assert(low < high); + + final List lineGraphemeBreaks = graphemeStarts; + assert(offset >= lineGraphemeBreaks[start]); + assert(offset < lineGraphemeBreaks.last, '$offset, $lineGraphemeBreaks'); + assert(end == lineGraphemeBreaks.length || offset < lineGraphemeBreaks[end]); + while (low + 2 <= high) { + // high >= low + 2, so low + 1 <= mid <= high - 1 + final int mid = (low + high) ~/ 2; + switch (lineGraphemeBreaks[mid] - offset) { + case > 0: high = mid; + case < 0: low = mid; + case == 0: return mid; + } + } + + assert(lineGraphemeBreaks[low] <= offset); + assert(high == lineGraphemeBreaks.length || offset < lineGraphemeBreaks[high]); + return low; + } + + /// Returns the UTF-16 range of the character that encloses the code unit at + /// the given offset. + ui.TextRange? getCharacterRangeAt(int codeUnitOffset) { + assert(codeUnitOffset >= this.startIndex); + if (codeUnitOffset >= visibleEndIndex || graphemeStarts.isEmpty) { + return null; + } + + final int startIndex = graphemeStartIndexBefore(codeUnitOffset, 0, graphemeStarts.length); + assert(startIndex < graphemeStarts.length - 1); + return ui.TextRange(start: graphemeStarts[startIndex], end: graphemeStarts[startIndex + 1]); + } + + LayoutFragment? closestFragmentTo(LayoutFragment targetFragment, bool searchLeft) { + ({LayoutFragment fragment, double distance})? closestFragment; + for (final LayoutFragment fragment in fragments) { + assert(fragment is! EllipsisFragment); + if (fragment.start >= visibleEndIndex) { + break; + } + if (fragment.graphemeStartIndexRange == null) { + continue; + } + final double distance = searchLeft + ? targetFragment.left - fragment.right + : fragment.left - targetFragment.right; + final double? minDistance = closestFragment?.distance; + switch (distance) { + case > 0.0 when minDistance == null || minDistance > distance: + closestFragment = (fragment: fragment, distance: distance); + case == 0.0: return fragment; + case _: continue; + } + } + return closestFragment?.fragment; + } + + /// Finds the closest [LayoutFragment] to the given horizontal offset `dx` in + /// this line, that is not an [EllipsisFragment] and contains at least one + /// grapheme start. + LayoutFragment? closestFragmentAtOffset(double dx) { + if (graphemeStarts.isEmpty) { + return null; + } + assert(graphemeStarts.length >= 2); + int graphemeIndex = 0; + ({LayoutFragment fragment, double distance})? closestFragment; + for (final LayoutFragment fragment in fragments) { + assert(fragment is! EllipsisFragment); + if (fragment.start >= visibleEndIndex) { + break; + } + if (fragment.length == 0) { + continue; + } + while (fragment.start > graphemeStarts[graphemeIndex]) { + graphemeIndex += 1; + } + final int firstGraphemeStartInFragment = graphemeStarts[graphemeIndex]; + if (firstGraphemeStartInFragment >= fragment.end) { + continue; + } + final double distance; + if (dx < fragment.left) { + distance = fragment.left - dx; + } else if (dx > fragment.right) { + distance = dx - fragment.right; + } else { + return fragment; + } + assert(distance > 0); + + final double? minDistance = closestFragment?.distance; + if (minDistance == null || minDistance > distance) { + closestFragment = (fragment: fragment, distance: distance); + } + } + return closestFragment?.fragment; + } + @override int get hashCode => Object.hash( lineMetrics, diff --git a/lib/web_ui/lib/text.dart b/lib/web_ui/lib/text.dart index ba09ee5068af4..adddffae4f863 100644 --- a/lib/web_ui/lib/text.dart +++ b/lib/web_ui/lib/text.dart @@ -220,6 +220,31 @@ class FontVariation { String toString() => "FontVariation('$axis', $value)"; } +final class GlyphInfo { + GlyphInfo(this.graphemeClusterLayoutBounds, this.graphemeClusterCodeUnitRange, this.writingDirection); + + final Rect graphemeClusterLayoutBounds; + final TextRange graphemeClusterCodeUnitRange; + final TextDirection writingDirection; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is GlyphInfo + && graphemeClusterLayoutBounds == other.graphemeClusterLayoutBounds + && graphemeClusterCodeUnitRange == other.graphemeClusterCodeUnitRange + && writingDirection == other.writingDirection; + } + + @override + int get hashCode => Object.hash(graphemeClusterLayoutBounds, graphemeClusterCodeUnitRange, writingDirection); + + @override + String toString() => 'Glyph($graphemeClusterLayoutBounds, textRange: $graphemeClusterCodeUnitRange, direction: $writingDirection)'; +} + // The order of this enum must match the order of the values in RenderStyleConstants.h's ETextAlign. enum TextAlign { left, @@ -691,6 +716,8 @@ abstract class Paragraph { {BoxHeightStyle boxHeightStyle = BoxHeightStyle.tight, BoxWidthStyle boxWidthStyle = BoxWidthStyle.tight}); TextPosition getPositionForOffset(Offset offset); + GlyphInfo? getGlyphInfoAt(int codeUnitOffset); + GlyphInfo? getClosestGlyphInfoForOffset(Offset offset); TextRange getWordBoundary(TextPosition position); TextRange getLineBoundary(TextPosition position); List getBoxesForPlaceholders(); diff --git a/lib/web_ui/skwasm/text/paragraph.cpp b/lib/web_ui/skwasm/text/paragraph.cpp index a44baf4d1ac39..0b94bdc03bac5 100644 --- a/lib/web_ui/skwasm/text/paragraph.cpp +++ b/lib/web_ui/skwasm/text/paragraph.cpp @@ -4,6 +4,9 @@ #include "third_party/skia/modules/skparagraph/include/Paragraph.h" #include "../export.h" +#include "DartTypes.h" +#include "TextStyle.h" +#include "include/core/SkScalar.h" using namespace skia::textlayout; @@ -58,6 +61,49 @@ SKWASM_EXPORT int32_t paragraph_getPositionForOffset(Paragraph* paragraph, return position.position; } +SKWASM_EXPORT bool paragraph_getClosestGlyphInfoAtCoordinate( + Paragraph* paragraph, + SkScalar offsetX, + SkScalar offsetY, + // Out parameters: + SkRect* graphemeLayoutBounds, // 1 SkRect + size_t* graphemeCodeUnitRange, // 2 size_ts: [start, end] + bool* booleanFlags) { // 1 boolean: isLTR + Paragraph::GlyphInfo glyphInfo; + if (!paragraph->getClosestUTF16GlyphInfoAt(offsetX, offsetY, &glyphInfo)) { + return false; + } + // This is more verbose than memcpying the whole struct but ideally we don't + // want to depend on the exact memory layout of the struct. + std::memcpy(graphemeLayoutBounds, &glyphInfo.fGraphemeLayoutBounds, + sizeof(SkRect)); + std::memcpy(graphemeCodeUnitRange, &glyphInfo.fGraphemeClusterTextRange, + 2 * sizeof(size_t)); + booleanFlags[0] = + glyphInfo.fDirection == skia::textlayout::TextDirection::kLtr; + return true; +} + +SKWASM_EXPORT bool paragraph_getGlyphInfoAt( + Paragraph* paragraph, + size_t index, + // Out parameters: + SkRect* graphemeLayoutBounds, // 1 SkRect + size_t* graphemeCodeUnitRange, // 2 size_ts: [start, end] + bool* booleanFlags) { // 1 boolean: isLTR + Paragraph::GlyphInfo glyphInfo; + if (!paragraph->getGlyphInfoAtUTF16Offset(index, &glyphInfo)) { + return false; + } + std::memcpy(graphemeLayoutBounds, &glyphInfo.fGraphemeLayoutBounds, + sizeof(SkRect)); + std::memcpy(graphemeCodeUnitRange, &glyphInfo.fGraphemeClusterTextRange, + 2 * sizeof(size_t)); + booleanFlags[0] = + glyphInfo.fDirection == skia::textlayout::TextDirection::kLtr; + return true; +} + SKWASM_EXPORT void paragraph_getWordBoundary( Paragraph* paragraph, unsigned int position, diff --git a/lib/web_ui/test/canvaskit/text_test.dart b/lib/web_ui/test/canvaskit/text_test.dart index d1c60a48b7e83..e99f03f9e5953 100644 --- a/lib/web_ui/test/canvaskit/text_test.dart +++ b/lib/web_ui/test/canvaskit/text_test.dart @@ -153,7 +153,7 @@ void testMain() { expect(paragraph.numberOfLines, 1); expect(paragraph.getLineMetricsAt(-1), isNull); - expect(paragraph.getLineMetricsAt(0), isNotNull); + expect(paragraph.getLineMetricsAt(0)?.lineNumber, 0); expect(paragraph.getLineMetricsAt(1), isNull); expect(paragraph.getLineNumberAt(-1), isNull); @@ -163,6 +163,47 @@ void testMain() { expect(paragraph.getLineMetricsAt(7), isNull); }); + test('Basic glyph metrics', () { + const double fontSize = 10; + final ui.ParagraphBuilder builder = ui.ParagraphBuilder(CkParagraphStyle( + fontStyle: ui.FontStyle.normal, + fontWeight: ui.FontWeight.normal, + fontFamily: 'FlutterTest', + fontSize: fontSize, + maxLines: 1, + ellipsis: 'BBB', + ))..addText('A' * 100); + final ui.Paragraph paragraph = builder.build(); + paragraph.layout(const ui.ParagraphConstraints(width: 100.0)); + + expect(paragraph.getGlyphInfoAt(-1), isNull); + + // The last 3 characters on the first line are ellipsized with BBB. + expect(paragraph.getGlyphInfoAt(0)?.graphemeClusterCodeUnitRange, const ui.TextRange(start: 0, end: 1)); + expect(paragraph.getGlyphInfoAt(6)?.graphemeClusterCodeUnitRange, const ui.TextRange(start: 6, end: 7)); + expect(paragraph.getGlyphInfoAt(7), isNull); + expect(paragraph.getGlyphInfoAt(200), isNull); + }); + + test('Basic glyph metrics - hit test', () { + const double fontSize = 10.0; + final ui.ParagraphBuilder builder = ui.ParagraphBuilder(CkParagraphStyle( + fontSize: fontSize, + fontFamily: 'FlutterTest', + ))..addText('Test\nTest'); + final ui.Paragraph paragraph = builder.build(); + paragraph.layout(const ui.ParagraphConstraints(width: double.infinity)); + + final ui.GlyphInfo? bottomRight = paragraph.getClosestGlyphInfoForOffset(const ui.Offset(99.0, 99.0)); + final ui.GlyphInfo? last = paragraph.getGlyphInfoAt(8); + expect(bottomRight, equals(last)); + expect(bottomRight, isNot(paragraph.getGlyphInfoAt(0))); + + expect(bottomRight?.graphemeClusterLayoutBounds, const ui.Rect.fromLTWH(30, 10, 10, 10)); + expect(bottomRight?.graphemeClusterCodeUnitRange, const ui.TextRange(start: 8, end: 9)); + expect(bottomRight?.writingDirection, ui.TextDirection.ltr); + }); + test('rounding hack disabled by default', () { expect(ui.ParagraphBuilder.shouldDisableRoundingHack, isTrue); diff --git a/lib/web_ui/test/html/text_test.dart b/lib/web_ui/test/html/text_test.dart index 644cbc225aee4..7d4303188bed0 100644 --- a/lib/web_ui/test/html/text_test.dart +++ b/lib/web_ui/test/html/text_test.dart @@ -105,7 +105,7 @@ Future testMain() async { expect(paragraph.numberOfLines, 1); expect(paragraph.getLineMetricsAt(-1), isNull); - expect(paragraph.getLineMetricsAt(0), isNotNull); + expect(paragraph.getLineMetricsAt(0)?.lineNumber, 0); expect(paragraph.getLineMetricsAt(1), isNull); expect(paragraph.getLineNumberAt(-1), isNull); @@ -115,6 +115,68 @@ Future testMain() async { expect(paragraph.getLineNumberAt(7), isNull); }); + test('Basic glyph metrics', () { + const double fontSize = 10; + final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle( + fontStyle: FontStyle.normal, + fontWeight: FontWeight.normal, + fontSize: fontSize, + maxLines: 1, + ellipsis: 'BBB', + ))..addText('A' * 100); + final Paragraph paragraph = builder.build(); + paragraph.layout(const ParagraphConstraints(width: 100.0)); + + expect(paragraph.getGlyphInfoAt(-1), isNull); + + // The last 3 characters on the first line are ellipsized with BBB. + expect(paragraph.getGlyphInfoAt(0)?.graphemeClusterCodeUnitRange, const TextRange(start: 0, end: 1)); + expect(paragraph.getGlyphInfoAt(6)?.graphemeClusterCodeUnitRange, const TextRange(start: 6, end: 7)); + expect(paragraph.getGlyphInfoAt(7), isNull); + expect(paragraph.getGlyphInfoAt(200), isNull); + }); + + test('Basic glyph metrics - hit test', () { + const double fontSize = 10.0; + final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle( + fontSize: fontSize, + fontFamily: 'FlutterTest', + ))..addText('Test\nTest'); + final Paragraph paragraph = builder.build(); + paragraph.layout(const ParagraphConstraints(width: double.infinity)); + + final GlyphInfo? bottomRight = paragraph.getClosestGlyphInfoForOffset(const Offset(99.0, 99.0)); + final GlyphInfo? last = paragraph.getGlyphInfoAt(8); + expect(bottomRight, equals(last)); + expect(bottomRight, isNot(paragraph.getGlyphInfoAt(0))); + + expect(bottomRight?.graphemeClusterLayoutBounds, const Rect.fromLTWH(30, 10, 10, 10)); + expect(bottomRight?.graphemeClusterCodeUnitRange, const TextRange(start: 8, end: 9)); + expect(bottomRight?.writingDirection, TextDirection.ltr); + }); + + test('Glyph metrics with grapheme split into different runs', () { + const double fontSize = 10; + final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle( + fontStyle: FontStyle.normal, + fontWeight: FontWeight.normal, + fontSize: fontSize, + maxLines: 1, + ))..addText('A') // The base charater A. + ..addText('̀'); // The diacritical grave accent, which should combine with the base character to form a single grapheme. + final Paragraph paragraph = builder.build(); + paragraph.layout(const ParagraphConstraints(width: double.infinity)); + + expect(paragraph.getGlyphInfoAt(0)?.graphemeClusterCodeUnitRange, const TextRange(start: 0, end: 2)); + expect(paragraph.getGlyphInfoAt(0)?.graphemeClusterLayoutBounds, const Rect.fromLTWH(0.0, 0.0, 10.0, 10.0)); + expect(paragraph.getGlyphInfoAt(1)?.graphemeClusterCodeUnitRange, const TextRange(start: 0, end: 2)); + expect(paragraph.getGlyphInfoAt(1)?.graphemeClusterLayoutBounds, const Rect.fromLTWH(0.0, 0.0, 10.0, 10.0)); + + final GlyphInfo? bottomRight = paragraph.getClosestGlyphInfoForOffset(const Offset(99.0, 99.0)); + expect(bottomRight?.graphemeClusterCodeUnitRange, const TextRange(start: 0, end: 2)); + expect(bottomRight?.graphemeClusterLayoutBounds, const Rect.fromLTWH(0.0, 0.0, 10.0, 10.0)); + }, skip: domIntl.v8BreakIterator == null); // Intended: Intl.v8breakiterator is needed for correctly breaking grapheme clusters. + test('Can disable rounding hack', () { if (!ParagraphBuilder.shouldDisableRoundingHack) { ParagraphBuilder.setDisableRoundingHack(true); diff --git a/testing/dart/paragraph_test.dart b/testing/dart/paragraph_test.dart index 4af61bb936345..35bd0ccb68007 100644 --- a/testing/dart/paragraph_test.dart +++ b/testing/dart/paragraph_test.dart @@ -259,9 +259,15 @@ void main() { )).build(); paragraph.layout(const ParagraphConstraints(width: double.infinity)); + expect(paragraph.getClosestGlyphInfoForOffset(Offset.zero), isNull); + expect(paragraph.getGlyphInfoAt(0), isNull); + expect(paragraph.getLineMetricsAt(0), isNull); expect(paragraph.numberOfLines, 0); expect(paragraph.getLineNumberAt(0), isNull); + + expect(paragraph.getGlyphInfoAt(0), isNull); + expect(paragraph.getClosestGlyphInfoForOffset(Offset.zero), isNull); }); test('OOB indices as input', () { @@ -277,7 +283,7 @@ void main() { expect(paragraph.numberOfLines, 1); expect(paragraph.getLineMetricsAt(-1), isNull); - expect(paragraph.getLineMetricsAt(0), isNotNull); + expect(paragraph.getLineMetricsAt(0)?.lineNumber, 0); expect(paragraph.getLineMetricsAt(1), isNull); expect(paragraph.getLineNumberAt(-1), isNull); @@ -285,6 +291,31 @@ void main() { expect(paragraph.getLineNumberAt(6), 0); // The last 3 characters on the first line are ellipsized with BBB. expect(paragraph.getLineMetricsAt(7), isNull); + + expect(paragraph.getGlyphInfoAt(-1), isNull); + expect(paragraph.getGlyphInfoAt(0)?.graphemeClusterCodeUnitRange, const TextRange(start: 0, end: 1)); + expect(paragraph.getGlyphInfoAt(6)?.graphemeClusterCodeUnitRange, const TextRange(start: 6, end: 7)); + expect(paragraph.getGlyphInfoAt(7), isNull); + expect(paragraph.getGlyphInfoAt(200), isNull); + }); + + test('querying glyph info', () { + const double fontSize = 10.0; + final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle( + fontSize: fontSize, + )); + builder.addText('Test\nTest'); + final Paragraph paragraph = builder.build(); + paragraph.layout(const ParagraphConstraints(width: double.infinity)); + + final GlyphInfo? bottomRight = paragraph.getClosestGlyphInfoForOffset(const Offset(99.0, 99.0)); + final GlyphInfo? last = paragraph.getGlyphInfoAt(8); + expect(bottomRight, equals(last)); + expect(bottomRight, notEquals(paragraph.getGlyphInfoAt(0))); + + expect(bottomRight?.graphemeClusterLayoutBounds, const Rect.fromLTWH(30, 10, 10, 10)); + expect(bottomRight?.graphemeClusterCodeUnitRange, const TextRange(start: 8, end: 9)); + expect(bottomRight?.writingDirection, TextDirection.ltr); }); test('painting a disposed paragraph does not crash', () { diff --git a/third_party/txt/src/skia/paragraph_skia.cc b/third_party/txt/src/skia/paragraph_skia.cc index cbb7b85d01f41..ca03a4a62b49f 100644 --- a/third_party/txt/src/skia/paragraph_skia.cc +++ b/third_party/txt/src/skia/paragraph_skia.cc @@ -378,6 +378,19 @@ Paragraph::PositionWithAffinity ParagraphSkia::GetGlyphPositionAtCoordinate( skia_pos.position, static_cast(skia_pos.affinity)); } +bool ParagraphSkia::GetGlyphInfoAt( + unsigned offset, + skia::textlayout::Paragraph::GlyphInfo* glyphInfo) const { + return paragraph_->getGlyphInfoAtUTF16Offset(offset, glyphInfo); +} + +bool ParagraphSkia::GetClosestGlyphInfoAtCoordinate( + double dx, + double dy, + skia::textlayout::Paragraph::GlyphInfo* glyphInfo) const { + return paragraph_->getClosestUTF16GlyphInfoAt(dx, dy, glyphInfo); +}; + Paragraph::Range ParagraphSkia::GetWordBoundary(size_t offset) { skt::SkRange range = paragraph_->getWordBoundary(offset); return Paragraph::Range(range.start, range.end); diff --git a/third_party/txt/src/skia/paragraph_skia.h b/third_party/txt/src/skia/paragraph_skia.h index bc23b78538b45..a33c2de8530fc 100644 --- a/third_party/txt/src/skia/paragraph_skia.h +++ b/third_party/txt/src/skia/paragraph_skia.h @@ -75,6 +75,15 @@ class ParagraphSkia : public Paragraph { PositionWithAffinity GetGlyphPositionAtCoordinate(double dx, double dy) override; + bool GetGlyphInfoAt( + unsigned offset, + skia::textlayout::Paragraph::GlyphInfo* glyphInfo) const override; + + bool GetClosestGlyphInfoAtCoordinate( + double dx, + double dy, + skia::textlayout::Paragraph::GlyphInfo* glyphInfo) const override; + Range GetWordBoundary(size_t offset) override; private: diff --git a/third_party/txt/src/txt/paragraph.h b/third_party/txt/src/txt/paragraph.h index b8ee55d4366f5..4655ca219ef19 100644 --- a/third_party/txt/src/txt/paragraph.h +++ b/third_party/txt/src/txt/paragraph.h @@ -21,6 +21,7 @@ #include "flutter/display_list/dl_builder.h" #include "line_metrics.h" #include "paragraph_style.h" +#include "third_party/skia/include/core/SkFont.h" #include "third_party/skia/include/core/SkRect.h" #include "third_party/skia/modules/skparagraph/include/Metrics.h" #include "third_party/skia/modules/skparagraph/include/Paragraph.h" @@ -175,6 +176,15 @@ class Paragraph { virtual PositionWithAffinity GetGlyphPositionAtCoordinate(double dx, double dy) = 0; + virtual bool GetGlyphInfoAt( + unsigned offset, + skia::textlayout::Paragraph::GlyphInfo* glyphInfo) const = 0; + + virtual bool GetClosestGlyphInfoAtCoordinate( + double dx, + double dy, + skia::textlayout::Paragraph::GlyphInfo* glyphInfo) const = 0; + // Finds the first and last glyphs that define a word containing the glyph at // index offset. virtual Range GetWordBoundary(size_t offset) = 0;