Reland "Expose more methods on ui.Paragraph: lines" (#47584) (flutter/engine#47623)

The diff is in [this commit](305d930fe1).

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
This commit is contained in:
LongCatIsLooong 2023-11-02 16:16:16 -07:00 committed by GitHub
parent 80a1a1d5ec
commit ef6aba043c
18 changed files with 373 additions and 25 deletions

View File

@ -164,6 +164,7 @@ source_set("ui") {
"//flutter/shell/common:display",
"//flutter/shell/common:platform_message_handler",
"//flutter/third_party/txt",
"//third_party/skia/modules/skparagraph",
]
deps = [

View File

@ -218,6 +218,9 @@ typedef CanvasPath Path;
V(Paragraph, didExceedMaxLines, 1) \
V(Paragraph, dispose, 1) \
V(Paragraph, getLineBoundary, 2) \
V(Paragraph, getLineMetricsAt, 3) \
V(Paragraph, getLineNumberAt, 2) \
V(Paragraph, getNumberOfLines, 1) \
V(Paragraph, getPositionForOffset, 3) \
V(Paragraph, getRectsForPlaceholders, 1) \
V(Paragraph, getRectsForRange, 5) \

View File

@ -2772,6 +2772,18 @@ class LineMetrics {
required this.lineNumber,
});
LineMetrics._(
this.hardBreak,
this.ascent,
this.descent,
this.unscaledAscent,
this.height,
this.width,
this.left,
this.baseline,
this.lineNumber,
);
/// True if this line ends with an explicit line break (e.g. '\n') or is the end
/// of the paragraph. False otherwise.
final bool hardBreak;
@ -2992,6 +3004,32 @@ abstract class Paragraph {
/// to repeatedly call this. Instead, cache the results.
List<LineMetrics> computeLineMetrics();
/// Returns the [LineMetrics] for the line at `lineNumber`, or null if the
/// given `lineNumber` is greater than or equal to [numberOfLines].
LineMetrics? getLineMetricsAt(int lineNumber);
/// The total number of visible lines in the paragraph.
///
/// Returns a non-negative number. If `maxLines` is non-null, the value of
/// [numberOfLines] never exceeds `maxLines`.
int get numberOfLines;
/// Returns the line number of the line that contains the code unit that
/// `codeUnitOffset` points to.
///
/// This method returns null if the given `codeUnitOffset` is out of bounds, or
/// is logically after the last visible codepoint. This includes the case where
/// its codepoint belongs to a visible line, but the text layout library
/// replaced it with an ellipsis.
///
/// If the target code unit points to a control character that introduces
/// mandatory line breaks (most notably the line feed character `LF`, typically
/// represented in strings as the escape sequence "\n"), to conform to
/// [the unicode rules](https://unicode.org/reports/tr14/#LB4), the control
/// character itself is always considered to be at the end of "current" line
/// rather than the beginning of the new line.
int? getLineNumberAt(int codeUnitOffset);
/// Release the resources used by this object. The object is no longer usable
/// after this method is called.
void dispose();
@ -3169,6 +3207,23 @@ base class _NativeParagraph extends NativeFieldWrapperClass1 implements Paragrap
@Native<Handle Function(Pointer<Void>)>(symbol: 'Paragraph::computeLineMetrics')
external Float64List _computeLineMetrics();
@override
LineMetrics? getLineMetricsAt(int lineNumber) => _getLineMetricsAt(lineNumber, LineMetrics._);
@Native<Handle Function(Pointer<Void>, Uint32, Handle)>(symbol: 'Paragraph::getLineMetricsAt')
external LineMetrics? _getLineMetricsAt(int lineNumber, Function constructor);
@override
@Native<Uint32 Function(Pointer<Void>)>(symbol: 'Paragraph::getNumberOfLines')
external int get numberOfLines;
@override
int? getLineNumberAt(int codeUnitOffset) {
final int lineNumber = _getLineNumber(codeUnitOffset);
return lineNumber < 0 ? null : lineNumber;
}
@Native<Int32 Function(Pointer<Void>, Uint32)>(symbol: 'Paragraph::getLineNumberAt')
external int _getLineNumber(int codeUnitOffset);
@override
void dispose() {
assert(!_disposed);

View File

@ -8,10 +8,14 @@
#include "flutter/common/task_runners.h"
#include "flutter/fml/logging.h"
#include "flutter/fml/task_runner.h"
#include "third_party/dart/runtime/include/dart_api.h"
#include "third_party/skia/modules/skparagraph/include/DartTypes.h"
#include "third_party/skia/modules/skparagraph/include/Paragraph.h"
#include "third_party/tonic/converter/dart_converter.h"
#include "third_party/tonic/dart_args.h"
#include "third_party/tonic/dart_binding_macros.h"
#include "third_party/tonic/dart_library_natives.h"
#include "third_party/tonic/logging/dart_invoke.h"
namespace flutter {
@ -122,12 +126,12 @@ Dart_Handle Paragraph::getWordBoundary(unsigned offset) {
return tonic::DartConverter<decltype(result)>::ToDart(result);
}
Dart_Handle Paragraph::getLineBoundary(unsigned offset) {
Dart_Handle Paragraph::getLineBoundary(unsigned utf16Offset) {
std::vector<txt::LineMetrics> metrics = m_paragraph->GetLineMetrics();
int line_start = -1;
int line_end = -1;
for (txt::LineMetrics& line : metrics) {
if (offset >= line.start_index && offset <= line.end_index) {
if (utf16Offset >= line.start_index && utf16Offset <= line.end_index) {
line_start = line.start_index;
line_end = line.end_index;
break;
@ -137,7 +141,7 @@ Dart_Handle Paragraph::getLineBoundary(unsigned offset) {
return tonic::DartConverter<decltype(result)>::ToDart(result);
}
tonic::Float64List Paragraph::computeLineMetrics() {
tonic::Float64List Paragraph::computeLineMetrics() const {
std::vector<txt::LineMetrics> metrics = m_paragraph->GetLineMetrics();
// Layout:
@ -165,6 +169,42 @@ tonic::Float64List Paragraph::computeLineMetrics() {
return result;
}
Dart_Handle Paragraph::getLineMetricsAt(int lineNumber,
Dart_Handle constructor) const {
skia::textlayout::LineMetrics line;
const bool found = m_paragraph->GetLineMetricsAt(lineNumber, &line);
if (!found) {
return Dart_Null();
}
std::array<Dart_Handle, 9> arguments = {
Dart_NewBoolean(line.fHardBreak),
Dart_NewDouble(line.fAscent),
Dart_NewDouble(line.fDescent),
Dart_NewDouble(line.fUnscaledAscent),
// We add then round to get the height. The
// definition of height here is different
// than the one in LibTxt.
Dart_NewDouble(round(line.fAscent + line.fDescent)),
Dart_NewDouble(line.fWidth),
Dart_NewDouble(line.fLeft),
Dart_NewDouble(line.fBaseline),
Dart_NewInteger(line.fLineNumber),
};
Dart_Handle handle =
Dart_InvokeClosure(constructor, arguments.size(), arguments.data());
tonic::CheckAndHandleError(handle);
return handle;
}
size_t Paragraph::getNumberOfLines() const {
return m_paragraph->GetNumberOfLines();
}
int Paragraph::getLineNumberAt(size_t utf16Offset) const {
return m_paragraph->GetLineNumberAt(utf16Offset);
}
void Paragraph::dispose() {
m_paragraph.reset();
ClearDartWrapper();

View File

@ -47,7 +47,10 @@ class Paragraph : public RefCountedDartWrappable<Paragraph> {
Dart_Handle getPositionForOffset(double dx, double dy);
Dart_Handle getWordBoundary(unsigned offset);
Dart_Handle getLineBoundary(unsigned offset);
tonic::Float64List computeLineMetrics();
tonic::Float64List computeLineMetrics() const;
Dart_Handle getLineMetricsAt(int lineNumber, Dart_Handle constructor) const;
size_t getNumberOfLines() const;
int getLineNumberAt(size_t utf16Offset) const;
void dispose();

View File

@ -3237,6 +3237,18 @@ extension SkParagraphExtension on SkParagraph {
List<SkLineMetrics> getLineMetrics() =>
_getLineMetrics().toDart.cast<SkLineMetrics>();
@JS('getLineMetricsAt')
external SkLineMetrics? _getLineMetricsAt(JSNumber index);
SkLineMetrics? getLineMetricsAt(double index) => _getLineMetricsAt(index.toJS);
@JS('getNumberOfLines')
external JSNumber _getNumberOfLines();
double getNumberOfLines() => _getNumberOfLines().toDartDouble;
@JS('getLineNumberAt')
external JSNumber _getLineNumberAt(JSNumber index);
double getLineNumberAt(double index) => _getLineNumberAt(index.toJS).toDartDouble;
@JS('getLongestLine')
external JSNumber _getLongestLine();
double getLongestLine() => _getLongestLine().toDartDouble;

View File

@ -728,6 +728,26 @@ class CkParagraph implements ui.Paragraph {
return result;
}
@override
ui.LineMetrics? getLineMetricsAt(int lineNumber) {
assert(!_disposed, 'Paragraph has been disposed.');
final SkLineMetrics? metrics = skiaObject.getLineMetricsAt(lineNumber.toDouble());
return metrics == null ? null : CkLineMetrics._(metrics);
}
@override
int get numberOfLines {
assert(!_disposed, 'Paragraph has been disposed.');
return skiaObject.getNumberOfLines().toInt();
}
@override
int? getLineNumberAt(int codeUnitOffset) {
assert(!_disposed, 'Paragraph has been disposed.');
final int lineNumber = skiaObject.getLineNumberAt(codeUnitOffset.toDouble()).toInt();
return lineNumber >= 0 ? lineNumber : null;
}
bool _disposed = false;
@override

View File

@ -101,6 +101,15 @@ class SkwasmParagraph extends SkwasmObjectWrapper<RawParagraph> implements ui.Pa
@override
bool get didExceedMaxLines => paragraphGetDidExceedMaxLines(handle);
@override
int get numberOfLines => paragraphGetLineCount(handle);
@override
int? getLineNumberAt(int codeUnitOffset) {
final int lineNumber = paragraphGetLineNumberAt(handle, codeUnitOffset);
return lineNumber >= 0 ? lineNumber : null;
}
@override
void layout(ui.ParagraphConstraints constraints) {
paragraphLayout(handle, constraints.width);
@ -214,6 +223,12 @@ class SkwasmParagraph extends SkwasmObjectWrapper<RawParagraph> implements ui.Pa
(int index) => SkwasmLineMetrics._(paragraphGetLineMetricsAtIndex(handle, index))
);
}
@override
ui.LineMetrics? getLineMetricsAt(int index) {
final LineMetricsHandle lineMetrics = paragraphGetLineMetricsAtIndex(handle, index);
return lineMetrics == nullptr ? SkwasmLineMetrics._(lineMetrics) : null;
}
}
void withScopedFontList(

View File

@ -40,7 +40,7 @@ class CanvasParagraph implements ui.Paragraph {
final EngineParagraphStyle paragraphStyle;
/// The full textual content of the paragraph.
late String plainText;
final String plainText;
/// Whether this paragraph can be drawn on a bitmap canvas.
///
@ -221,17 +221,12 @@ class CanvasParagraph implements ui.Paragraph {
@override
ui.TextRange getLineBoundary(ui.TextPosition position) {
final int index = position.offset;
int i;
for (i = 0; i < lines.length - 1; i++) {
final ParagraphLine line = lines[i];
if (index >= line.startIndex && index < line.endIndex) {
break;
}
if (lines.isEmpty) {
return ui.TextRange.empty;
}
final ParagraphLine line = lines[i];
final int? lineNumber = getLineNumberAt(position.offset);
// Fallback to the last line for backward compatibility.
final ParagraphLine line = lineNumber != null ? lines[lineNumber] : lines.last;
return ui.TextRange(start: line.startIndex, end: line.endIndex - line.trailingNewlines);
}
@ -240,6 +235,32 @@ class CanvasParagraph implements ui.Paragraph {
return lines.map((ParagraphLine line) => line.lineMetrics).toList();
}
@override
EngineLineMetrics? getLineMetricsAt(int lineNumber) {
return 0 <= lineNumber && lineNumber < lines.length
? lines[lineNumber].lineMetrics
: null;
}
@override
int get numberOfLines => lines.length;
@override
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) {
return null;
}
if (endLine == startLine + 1) {
return startLine;
}
// endLine >= startLine + 2 thus we have
// startLine + 1 <= midIndex <= endLine - 1
final int midIndex = (startLine + endLine) ~/ 2;
return _findLine(codeUnitOffset, midIndex, endLine) ?? _findLine(codeUnitOffset, startLine, midIndex);
}
bool _disposed = false;
@override

View File

@ -390,7 +390,10 @@ class TextLayoutService {
// it possible to do hit testing. Once we find the box, we look inside that
// box to find where exactly the `offset` is located.
final ParagraphLine line = _findLineForY(offset.dy);
final ParagraphLine? line = _findLineForY(offset.dy);
if (line == null) {
return const ui.TextPosition(offset: 0);
}
// [offset] is to the left of the line.
if (offset.dx <= line.left) {
return ui.TextPosition(
@ -416,7 +419,10 @@ class TextLayoutService {
return ui.TextPosition(offset: line.startIndex);
}
ParagraphLine _findLineForY(double y) {
ParagraphLine? _findLineForY(double y) {
if (lines.isEmpty) {
return null;
}
// We could do a binary search here but it's not worth it because the number
// of line is typically low, and each iteration is a cheap comparison of
// doubles.

View File

@ -695,6 +695,9 @@ abstract class Paragraph {
TextRange getLineBoundary(TextPosition position);
List<TextBox> getBoxesForPlaceholders();
List<LineMetrics> computeLineMetrics();
LineMetrics? getLineMetricsAt(int lineNumber);
int get numberOfLines;
int? getLineNumberAt(int codeUnitOffset);
void dispose();
bool get debugDisposed;
}

View File

@ -74,14 +74,18 @@ SKWASM_EXPORT size_t paragraph_getLineCount(Paragraph* paragraph) {
SKWASM_EXPORT int paragraph_getLineNumberAt(Paragraph* paragraph,
size_t characterIndex) {
return paragraph->getLineNumberAt(characterIndex);
return paragraph->getLineNumberAtUTF16Offset(characterIndex);
}
SKWASM_EXPORT LineMetrics* paragraph_getLineMetricsAtIndex(Paragraph* paragraph,
size_t index) {
size_t lineNumber) {
auto metrics = new LineMetrics();
paragraph->getLineMetricsAt(index, metrics);
return metrics;
if (paragraph->getLineMetricsAt(lineNumber, metrics)) {
return metrics;
} else {
delete metrics;
return nullptr;
}
}
struct TextBoxList {

View File

@ -124,6 +124,43 @@ void testMain() {
});
});
test('empty paragraph', () {
const double fontSize = 10.0;
final ui.Paragraph paragraph = ui.ParagraphBuilder(CkParagraphStyle(
fontSize: fontSize,
)).build();
paragraph.layout(const ui.ParagraphConstraints(width: double.infinity));
expect(paragraph.getLineMetricsAt(0), isNull);
expect(paragraph.numberOfLines, 0);
expect(paragraph.getLineNumberAt(0), isNull);
});
test('Basic line related metrics', () {
const double fontSize = 10;
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(CkParagraphStyle(
fontStyle: ui.FontStyle.normal,
fontWeight: ui.FontWeight.normal,
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.numberOfLines, 1);
expect(paragraph.getLineMetricsAt(-1), isNull);
expect(paragraph.getLineMetricsAt(0), isNotNull);
expect(paragraph.getLineMetricsAt(1), isNull);
expect(paragraph.getLineNumberAt(-1), isNull);
expect(paragraph.getLineNumberAt(0), 0);
expect(paragraph.getLineNumberAt(6), 0);
// The last 3 characters on the first line are ellipsized with BBB.
expect(paragraph.getLineMetricsAt(7), isNull);
});
test('rounding hack disabled by default', () {
expect(ui.ParagraphBuilder.shouldDisableRoundingHack, isTrue);

View File

@ -79,10 +79,6 @@ Future<void> testMain() async {
expect(paragraph.height, fontSize * 2.0); // because it wraps
expect(paragraph.width, fontSize * 5.0);
expect(paragraph.minIntrinsicWidth, fontSize * 4.0);
// TODO(yjbanov): due to https://github.com/flutter/flutter/issues/21965
// Flutter reports a different number. Ours is correct
// though.
expect(paragraph.maxIntrinsicWidth, fontSize * 9.0);
expect(paragraph.alphabeticBaseline, fontSize * .8);
expect(
@ -94,6 +90,31 @@ Future<void> testMain() async {
}
});
test('Basic line related 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.numberOfLines, 1);
expect(paragraph.getLineMetricsAt(-1), isNull);
expect(paragraph.getLineMetricsAt(0), isNotNull);
expect(paragraph.getLineMetricsAt(1), isNull);
expect(paragraph.getLineNumberAt(-1), isNull);
expect(paragraph.getLineNumberAt(0), 0);
expect(paragraph.getLineNumberAt(6), 0);
// The last 3 characters on the first line are ellipsized with BBB.
expect(paragraph.getLineNumberAt(7), isNull);
});
test('Can disable rounding hack', () {
if (!ParagraphBuilder.shouldDisableRoundingHack) {
ParagraphBuilder.setDisableRoundingHack(true);

View File

@ -219,6 +219,74 @@ void main() {
expect(line.end, 10);
});
test('getLineMetricsAt', () {
const double fontSize = 10.0;
final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(
fontSize: fontSize,
textDirection: TextDirection.rtl,
height: 2.0,
));
builder.addText('Test\npppp');
final Paragraph paragraph = builder.build();
paragraph.layout(const ParagraphConstraints(width: 100.0));
final LineMetrics? line = paragraph.getLineMetricsAt(1);
expect(line?.hardBreak, isTrue);
expect(line?.ascent, 15.0);
expect(line?.descent, 5.0);
expect(line?.height, 20.0);
expect(line?.width, 4 * 10.0);
expect(line?.left, 100.0 - 40.0);
expect(line?.baseline, 20.0 + 15.0);
expect(line?.lineNumber, 1);
});
test('line number', () {
const double fontSize = 10.0;
final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(fontSize: fontSize));
builder.addText('Test\n\nTest');
final Paragraph paragraph = builder.build();
paragraph.layout(const ParagraphConstraints(width: 100.0));
expect(paragraph.numberOfLines, 3);
expect(paragraph.getLineNumberAt(4), 0); // first LF
expect(paragraph.getLineNumberAt(5), 1); // second LF
expect(paragraph.getLineNumberAt(6), 2); // "T" in the second "Test"
});
test('empty paragraph', () {
const double fontSize = 10.0;
final Paragraph paragraph = ParagraphBuilder(ParagraphStyle(
fontSize: fontSize,
)).build();
paragraph.layout(const ParagraphConstraints(width: double.infinity));
expect(paragraph.getLineMetricsAt(0), isNull);
expect(paragraph.numberOfLines, 0);
expect(paragraph.getLineNumberAt(0), isNull);
});
test('OOB indices as input', () {
const double fontSize = 10.0;
final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle(
fontSize: fontSize,
maxLines: 1,
ellipsis: 'BBB',
))..addText('A' * 100);
final Paragraph paragraph = builder.build();
paragraph.layout(const ParagraphConstraints(width: 100));
expect(paragraph.numberOfLines, 1);
expect(paragraph.getLineMetricsAt(-1), isNull);
expect(paragraph.getLineMetricsAt(0), isNotNull);
expect(paragraph.getLineMetricsAt(1), isNull);
expect(paragraph.getLineNumberAt(-1), isNull);
expect(paragraph.getLineNumberAt(0), 0);
expect(paragraph.getLineNumberAt(6), 0);
// The last 3 characters on the first line are ellipsized with BBB.
expect(paragraph.getLineMetricsAt(7), isNull);
});
test('painting a disposed paragraph does not crash', () {
final Paragraph paragraph = ParagraphBuilder(ParagraphStyle()).build();
paragraph.dispose();

View File

@ -301,6 +301,11 @@ std::vector<LineMetrics>& ParagraphSkia::GetLineMetrics() {
return line_metrics_.value();
}
bool ParagraphSkia::GetLineMetricsAt(int lineNumber,
skt::LineMetrics* lineMetrics) const {
return paragraph_->getLineMetricsAt(lineNumber, lineMetrics);
};
double ParagraphSkia::GetMinIntrinsicWidth() {
return SkScalarToDouble(paragraph_->getMinIntrinsicWidth());
}
@ -378,6 +383,14 @@ Paragraph::Range<size_t> ParagraphSkia::GetWordBoundary(size_t offset) {
return Paragraph::Range<size_t>(range.start, range.end);
}
size_t ParagraphSkia::GetNumberOfLines() const {
return paragraph_->lineNumber();
}
int ParagraphSkia::GetLineNumberAt(size_t codeUnitIndex) const {
return paragraph_->getLineNumberAtUTF16Offset(codeUnitIndex);
}
TextStyle ParagraphSkia::SkiaToTxt(const skt::TextStyle& skia) {
TextStyle txt;

View File

@ -50,6 +50,14 @@ class ParagraphSkia : public Paragraph {
std::vector<LineMetrics>& GetLineMetrics() override;
bool GetLineMetricsAt(
int lineNumber,
skia::textlayout::LineMetrics* lineMetrics) const override;
size_t GetNumberOfLines() const override;
int GetLineNumberAt(size_t utf16Offset) const override;
bool DidExceedMaxLines() override;
void Layout(double width) override;

View File

@ -22,6 +22,8 @@
#include "line_metrics.h"
#include "paragraph_style.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"
class SkCanvas;
@ -178,6 +180,22 @@ class Paragraph {
virtual Range<size_t> GetWordBoundary(size_t offset) = 0;
virtual std::vector<LineMetrics>& GetLineMetrics() = 0;
virtual bool GetLineMetricsAt(
int lineNumber,
skia::textlayout::LineMetrics* lineMetrics) const = 0;
// Returns the total number of visible lines in the paragraph.
virtual size_t GetNumberOfLines() const = 0;
// Returns the zero-indexed line number that contains the given code unit
// offset. Returns -1 if the given offset is out of bounds, or points to a
// codepoint that is logically after the last visible codepoint.
//
// If the offset points to a hard line break, this method returns the line
// number of the line this hard line break breaks, intead of the new line it
// creates.
virtual int GetLineNumberAt(size_t utf16Offset) const = 0;
};
} // namespace txt