mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
[web] Handle long text and ellipsis in rich text (flutter/engine#22873)
This commit is contained in:
parent
d9c92226af
commit
cad093bb5c
@ -41,7 +41,9 @@ class TextLayoutService {
|
||||
|
||||
int? get maxLines => paragraph.paragraphStyle._maxLines;
|
||||
bool get unlimitedLines => maxLines == null;
|
||||
bool get hasEllipsis => paragraph.paragraphStyle._ellipsis != null;
|
||||
|
||||
String? get ellipsis => paragraph.paragraphStyle._ellipsis;
|
||||
bool get hasEllipsis => ellipsis != null;
|
||||
|
||||
/// Performs the layout on a paragraph given the [constraints].
|
||||
///
|
||||
@ -73,13 +75,13 @@ class TextLayoutService {
|
||||
|
||||
int spanIndex = 0;
|
||||
ParagraphSpan span = paragraph.spans[0];
|
||||
LineBuilder currentLine = LineBuilder.first(paragraph, spanometer);
|
||||
LineBuilder maxIntrinsicLine = LineBuilder.first(paragraph, spanometer);
|
||||
LineBuilder currentLine =
|
||||
LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width);
|
||||
|
||||
// The only way to exit this while loop is by hitting the `break;` statement
|
||||
// when we reach the `endOfText` line break.
|
||||
// The only way to exit this while loop is by hitting one of the `break;`
|
||||
// statements (e.g. when we reach `endOfText`, when ellipsis has been
|
||||
// appended).
|
||||
while (true) {
|
||||
|
||||
// *********************************************** //
|
||||
// *** HANDLE HARD LINE BREAKS AND END OF TEXT *** //
|
||||
// *********************************************** //
|
||||
@ -103,7 +105,6 @@ class TextLayoutService {
|
||||
if (span is PlaceholderSpan) {
|
||||
spanometer.currentSpan = null;
|
||||
final double lineWidth = currentLine.width + span.width;
|
||||
// TODO(mdebbar): Consider how placeholders affect min/max intrinsics.
|
||||
if (lineWidth <= constraints.width) {
|
||||
// The placeholder fits on the current line.
|
||||
// TODO(mdebbar):
|
||||
@ -122,12 +123,6 @@ class TextLayoutService {
|
||||
final double additionalWidth =
|
||||
currentLine.getAdditionalWidthTo(nextBreak);
|
||||
|
||||
// For the purpose of max intrinsic width, we don't care if the line
|
||||
// fits within the constraints or not. So we always extend it.
|
||||
if (maxIntrinsicLine.end != nextBreak) {
|
||||
maxIntrinsicLine.extendTo(nextBreak);
|
||||
}
|
||||
|
||||
if (currentLine.width + additionalWidth <= constraints.width) {
|
||||
// TODO(mdebbar): Handle the case when `nextBreak` is just a span end
|
||||
// that shouldn't extend the line yet.
|
||||
@ -138,19 +133,23 @@ class TextLayoutService {
|
||||
// The chunk of text can't fit into the current line.
|
||||
final bool isLastLine =
|
||||
(hasEllipsis && unlimitedLines) || lines.length + 1 == maxLines;
|
||||
|
||||
if (isLastLine && hasEllipsis) {
|
||||
// We've reached the line that requires an ellipsis to be appended
|
||||
// to it.
|
||||
|
||||
// TODO(mdebbar): Remove this line and implement overflow ellipsis.
|
||||
currentLine.extendTo(nextBreak);
|
||||
currentLine.forceBreak(nextBreak,
|
||||
allowEmpty: true, ellipsis: ellipsis);
|
||||
lines.add(currentLine.build(ellipsis: ellipsis));
|
||||
break;
|
||||
} else if (currentLine.isEmpty) {
|
||||
// The current line is still empty, which means we are dealing
|
||||
// with a single block of text that doesn't fit in a single line.
|
||||
// We need to force-break it.
|
||||
// We need to force-break it without adding an ellipsis.
|
||||
|
||||
// TODO(mdebbar): Remove this line and implement force-breaking.
|
||||
currentLine.extendTo(nextBreak);
|
||||
currentLine.forceBreak(nextBreak, allowEmpty: false);
|
||||
lines.add(currentLine.build());
|
||||
currentLine = currentLine.nextLine();
|
||||
} else {
|
||||
// Normal line break.
|
||||
lines.add(currentLine.build());
|
||||
@ -161,24 +160,8 @@ class TextLayoutService {
|
||||
throw UnimplementedError('Unknown span type: ${span.runtimeType}');
|
||||
}
|
||||
|
||||
// ************************************************ //
|
||||
// *** LONGEST LINE && MAX/MIN INTRINSIC WIDTHS *** //
|
||||
// ************************************************ //
|
||||
|
||||
if (longestLine < currentLine.width) {
|
||||
longestLine = currentLine.width;
|
||||
}
|
||||
|
||||
if (minIntrinsicWidth < currentLine.widthOfLastExtension) {
|
||||
minIntrinsicWidth = currentLine.widthOfLastExtension;
|
||||
}
|
||||
|
||||
if (maxIntrinsicLine.end.isHard) {
|
||||
// Max intrinsic width includes the width of trailing spaces.
|
||||
if (maxIntrinsicWidth < maxIntrinsicLine.widthIncludingSpace) {
|
||||
maxIntrinsicWidth = maxIntrinsicLine.widthIncludingSpace;
|
||||
}
|
||||
maxIntrinsicLine = maxIntrinsicLine.nextLine();
|
||||
if (lines.length == maxLines) {
|
||||
break;
|
||||
}
|
||||
|
||||
// ********************************************* //
|
||||
@ -190,9 +173,85 @@ class TextLayoutService {
|
||||
span = paragraph.spans[++spanIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// ******************************** //
|
||||
// *** MAX/MIN INTRINSIC WIDTHS *** //
|
||||
// ******************************** //
|
||||
|
||||
spanIndex = 0;
|
||||
span = paragraph.spans[0];
|
||||
currentLine =
|
||||
LineBuilder.first(paragraph, spanometer, maxWidth: constraints.width);
|
||||
|
||||
while (currentLine.end.type != LineBreakType.endOfText) {
|
||||
if (span is PlaceholderSpan) {
|
||||
// TODO(mdebbar): Do placeholders affect min/max intrinsic width?
|
||||
} else if (span is FlatTextSpan) {
|
||||
final LineBreakResult nextBreak = currentLine.findNextBreak(span.end);
|
||||
|
||||
// For the purpose of max intrinsic width, we don't care if the line
|
||||
// fits within the constraints or not. So we always extend it.
|
||||
currentLine.extendTo(nextBreak);
|
||||
|
||||
final double widthOfLastSegment = currentLine.lastSegment.width;
|
||||
if (minIntrinsicWidth < widthOfLastSegment) {
|
||||
minIntrinsicWidth = widthOfLastSegment;
|
||||
}
|
||||
|
||||
if (currentLine.end.isHard) {
|
||||
// Max intrinsic width includes the width of trailing spaces.
|
||||
if (maxIntrinsicWidth < currentLine.widthIncludingSpace) {
|
||||
maxIntrinsicWidth = currentLine.widthIncludingSpace;
|
||||
}
|
||||
currentLine = currentLine.nextLine();
|
||||
}
|
||||
|
||||
// Only go to the next span if we've reached the end of this span.
|
||||
if (currentLine.end.index >= span.end && spanIndex < spanCount - 1) {
|
||||
span = paragraph.spans[++spanIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a segment in a line of a paragraph.
|
||||
///
|
||||
/// For example, this line: "Lorem ipsum dolor sit" is broken up into the
|
||||
/// following segments:
|
||||
///
|
||||
/// - "Lorem "
|
||||
/// - "ipsum "
|
||||
/// - "dolor "
|
||||
/// - "sit"
|
||||
class LineSegment {
|
||||
LineSegment({
|
||||
required this.span,
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.width,
|
||||
required this.widthIncludingSpace,
|
||||
});
|
||||
|
||||
/// The span that this segment belongs to.
|
||||
final ParagraphSpan span;
|
||||
|
||||
/// The index of the beginning of the segment in the paragraph.
|
||||
final LineBreakResult start;
|
||||
|
||||
/// The index of the end of the segment in the paragraph.
|
||||
final LineBreakResult end;
|
||||
|
||||
/// The width of the segment excluding any trailing white space.
|
||||
final double width;
|
||||
|
||||
/// The width of the segment including any trailing white space.
|
||||
final double widthIncludingSpace;
|
||||
|
||||
/// The width of the trailing white space in the segment.
|
||||
double get widthOfTrailingSpace => widthIncludingSpace - width;
|
||||
}
|
||||
|
||||
/// Builds instances of [EngineLineMetrics] for the given [paragraph].
|
||||
///
|
||||
/// Usage of this class starts by calling [LineBuilder.first] to start building
|
||||
@ -209,20 +268,29 @@ class LineBuilder {
|
||||
LineBuilder._(
|
||||
this.paragraph,
|
||||
this.spanometer, {
|
||||
required this.maxWidth,
|
||||
required this.start,
|
||||
required this.lineNumber,
|
||||
}) : end = start;
|
||||
|
||||
/// Creates a [LineBuilder] for the first line in a paragraph.
|
||||
factory LineBuilder.first(CanvasParagraph paragraph, Spanometer spanometer) {
|
||||
factory LineBuilder.first(
|
||||
CanvasParagraph paragraph,
|
||||
Spanometer spanometer, {
|
||||
required double maxWidth,
|
||||
}) {
|
||||
return LineBuilder._(
|
||||
paragraph,
|
||||
spanometer,
|
||||
maxWidth: maxWidth,
|
||||
lineNumber: 0,
|
||||
start: LineBreakResult.sameIndex(0, LineBreakType.prohibited),
|
||||
);
|
||||
}
|
||||
|
||||
final List<LineSegment> _segments = <LineSegment>[];
|
||||
|
||||
final double maxWidth;
|
||||
final CanvasParagraph paragraph;
|
||||
final Spanometer spanometer;
|
||||
final LineBreakResult start;
|
||||
@ -233,17 +301,17 @@ class LineBuilder {
|
||||
/// The width of the line so far, excluding trailing white space.
|
||||
double width = 0.0;
|
||||
|
||||
/// The width of trailing white space in the line.
|
||||
double widthOfTrailingSpace = 0.0;
|
||||
|
||||
/// The width of the line so far, including trailing white space.
|
||||
double get widthIncludingSpace => width + widthOfTrailingSpace;
|
||||
double widthIncludingSpace = 0.0;
|
||||
|
||||
/// The width of the last extension to the line made via [extendTo].
|
||||
double widthOfLastExtension = 0.0;
|
||||
/// The width of trailing white space in the line.
|
||||
double get widthOfTrailingSpace => widthIncludingSpace - width;
|
||||
|
||||
bool get isEmpty => start == end;
|
||||
bool get isNotEmpty => !isEmpty;
|
||||
/// The last segment in this line.
|
||||
LineSegment get lastSegment => _segments.last;
|
||||
|
||||
bool get isEmpty => _segments.isEmpty;
|
||||
bool get isNotEmpty => _segments.isNotEmpty;
|
||||
|
||||
/// Measures the width of text between the end of this line and [newEnd].
|
||||
double getAdditionalWidthTo(LineBreakResult newEnd) {
|
||||
@ -265,28 +333,137 @@ class LineBuilder {
|
||||
'Cannot extend a line that ends with a hard break.',
|
||||
);
|
||||
|
||||
// TODO(mdebbar): Handle the case where the entire extension is made of spaces.
|
||||
widthOfLastExtension = spanometer.measure(end, newEnd);
|
||||
final double additionalWidthIncludingSpace =
|
||||
spanometer.measureIncludingSpace(end, newEnd);
|
||||
_addSegment(_createSegment(newEnd));
|
||||
}
|
||||
|
||||
/// Creates a new segment to be appended to the end of this line.
|
||||
LineSegment _createSegment(LineBreakResult segmentEnd) {
|
||||
// The segment starts at the end of the line.
|
||||
final LineBreakResult segmentStart = end;
|
||||
return LineSegment(
|
||||
span: spanometer.currentSpan!,
|
||||
start: segmentStart,
|
||||
end: segmentEnd,
|
||||
width: spanometer.measure(segmentStart, segmentEnd),
|
||||
widthIncludingSpace:
|
||||
spanometer.measureIncludingSpace(segmentStart, segmentEnd),
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds a segment to this line.
|
||||
///
|
||||
/// It adjusts the width properties to accommodate the new segment. It also
|
||||
/// sets the line end to the end of the segment.
|
||||
void _addSegment(LineSegment segment) {
|
||||
_segments.add(segment);
|
||||
|
||||
// Add the width of previous trailing space.
|
||||
width += widthOfTrailingSpace + widthOfLastExtension;
|
||||
widthOfTrailingSpace = additionalWidthIncludingSpace - widthOfLastExtension;
|
||||
end = newEnd;
|
||||
width += widthOfTrailingSpace + segment.width;
|
||||
widthIncludingSpace += segment.widthIncludingSpace;
|
||||
end = segment.end;
|
||||
}
|
||||
|
||||
/// Removes the latest [LineSegment] added by [_addSegment].
|
||||
///
|
||||
/// It re-adjusts the width properties and the end of the line.
|
||||
LineSegment _popSegment() {
|
||||
final LineSegment poppedSegment = _segments.removeLast();
|
||||
|
||||
double widthOfPrevTrailingSpace;
|
||||
if (_segments.isEmpty) {
|
||||
widthOfPrevTrailingSpace = 0.0;
|
||||
end = start;
|
||||
} else {
|
||||
widthOfPrevTrailingSpace = lastSegment.widthOfTrailingSpace;
|
||||
end = lastSegment.end;
|
||||
}
|
||||
|
||||
width = width - poppedSegment.width - widthOfPrevTrailingSpace;
|
||||
widthIncludingSpace -= poppedSegment.widthIncludingSpace;
|
||||
|
||||
return poppedSegment;
|
||||
}
|
||||
|
||||
/// Force-breaks the line in order to fit in [maxWidth] while trying to extend
|
||||
/// to [nextBreak].
|
||||
///
|
||||
/// This should only be called when there isn't enough width to extend to
|
||||
/// [nextBreak], and either of the following is true:
|
||||
///
|
||||
/// 1. An ellipsis is being appended to this line, OR
|
||||
/// 2. The line doesn't have any line break opportunities and has to be
|
||||
/// force-broken.
|
||||
void forceBreak(
|
||||
LineBreakResult nextBreak, {
|
||||
required bool allowEmpty,
|
||||
String? ellipsis,
|
||||
}) {
|
||||
if (ellipsis == null) {
|
||||
final double availableWidth = maxWidth - widthIncludingSpace;
|
||||
final LineBreakResult breakingPoint = spanometer.forceBreak(
|
||||
end.index,
|
||||
nextBreak.indexWithoutTrailingSpaces,
|
||||
availableWidth: availableWidth,
|
||||
allowEmpty: allowEmpty,
|
||||
);
|
||||
extendTo(breakingPoint);
|
||||
return;
|
||||
}
|
||||
|
||||
// For example: "foo bar baz". Let's say all characters have the same width, and
|
||||
// the constraint width can only fit 9 characters "foo bar b". So if the
|
||||
// paragraph has an ellipsis, we can't just remove the last segment "baz"
|
||||
// and replace it with "..." because that would overflow.
|
||||
//
|
||||
// We need to keep popping segments until we are able to fit the "..."
|
||||
// without overflowing. In this example, that would be: "foo ba..."
|
||||
|
||||
final double ellipsisWidth = spanometer.measureText(ellipsis);
|
||||
final double availableWidth = maxWidth - ellipsisWidth;
|
||||
|
||||
// First, we create the new segment until `nextBreak`.
|
||||
LineSegment segmentToBreak = _createSegment(nextBreak);
|
||||
|
||||
// Then, we keep popping until we find the segment that has to be broken.
|
||||
// After the loop ends, two things are correct:
|
||||
// 1. All remaining segments in `_segments` can fit within constraints.
|
||||
// 2. Adding `segmentToBreak` causes the line to overflow.
|
||||
while (_segments.isNotEmpty && width > availableWidth) {
|
||||
segmentToBreak = _popSegment();
|
||||
}
|
||||
|
||||
spanometer.currentSpan = segmentToBreak.span as FlatTextSpan;
|
||||
final double availableWidthForSegment =
|
||||
availableWidth - widthIncludingSpace;
|
||||
final LineBreakResult breakingPoint = spanometer.forceBreak(
|
||||
segmentToBreak.start.index,
|
||||
segmentToBreak.end.indexWithoutTrailingSpaces,
|
||||
availableWidth: availableWidthForSegment,
|
||||
allowEmpty: allowEmpty,
|
||||
);
|
||||
extendTo(breakingPoint);
|
||||
}
|
||||
|
||||
/// Builds the [EngineLineMetrics] instance that represents this line.
|
||||
EngineLineMetrics build() {
|
||||
final String text = paragraph.toPlainText();
|
||||
EngineLineMetrics build({String? ellipsis}) {
|
||||
double ellipsisWidth = 0.0;
|
||||
String text = paragraph
|
||||
.toPlainText()
|
||||
.substring(start.index, end.indexWithoutTrailingNewlines);
|
||||
|
||||
if (ellipsis != null) {
|
||||
ellipsisWidth = spanometer.measureText(ellipsis);
|
||||
text += ellipsis;
|
||||
}
|
||||
|
||||
return EngineLineMetrics.withText(
|
||||
text.substring(start.index, end.indexWithoutTrailingNewlines),
|
||||
text,
|
||||
startIndex: start.index,
|
||||
endIndex: end.index,
|
||||
endIndexWithoutNewlines: end.indexWithoutTrailingNewlines,
|
||||
hardBreak: end.isHard,
|
||||
width: width,
|
||||
widthWithTrailingSpaces: width + widthOfTrailingSpace,
|
||||
width: width + ellipsisWidth,
|
||||
widthWithTrailingSpaces: widthIncludingSpace + ellipsisWidth,
|
||||
// TODO(mdebbar): Calculate actual align offset.
|
||||
left: 0.0,
|
||||
lineNumber: lineNumber,
|
||||
@ -303,6 +480,7 @@ class LineBuilder {
|
||||
return LineBuilder._(
|
||||
paragraph,
|
||||
spanometer,
|
||||
maxWidth: maxWidth,
|
||||
start: end,
|
||||
lineNumber: lineNumber + 1,
|
||||
);
|
||||
@ -328,6 +506,7 @@ class Spanometer {
|
||||
double? get letterSpacing => _currentSpan!.style._letterSpacing;
|
||||
|
||||
FlatTextSpan? _currentSpan;
|
||||
FlatTextSpan? get currentSpan => _currentSpan;
|
||||
set currentSpan(FlatTextSpan? span) {
|
||||
if (span == _currentSpan) {
|
||||
return;
|
||||
@ -361,6 +540,49 @@ class Spanometer {
|
||||
return _measure(start.index, end.indexWithoutTrailingNewlines);
|
||||
}
|
||||
|
||||
double measureText(String text) {
|
||||
return _measureSubstring(context, text, 0, text.length);
|
||||
}
|
||||
|
||||
LineBreakResult forceBreak(
|
||||
int start,
|
||||
int end, {
|
||||
required double availableWidth,
|
||||
required bool allowEmpty,
|
||||
}) {
|
||||
assert(_currentSpan != null);
|
||||
|
||||
final FlatTextSpan span = _currentSpan!;
|
||||
|
||||
// Make sure the range is within the current span.
|
||||
assert(start >= span.start && start <= span.end);
|
||||
assert(end >= span.start && end <= span.end);
|
||||
|
||||
if (availableWidth <= 0.0) {
|
||||
return LineBreakResult.sameIndex(
|
||||
allowEmpty ? start : start + 1, LineBreakType.prohibited);
|
||||
}
|
||||
|
||||
int low = start;
|
||||
int high = end;
|
||||
do {
|
||||
final int mid = (low + high) ~/ 2;
|
||||
final double width = _measure(start, mid);
|
||||
if (width < availableWidth) {
|
||||
low = mid;
|
||||
} else if (width > availableWidth) {
|
||||
high = mid;
|
||||
} else {
|
||||
low = high = mid;
|
||||
}
|
||||
} while (high - low > 1);
|
||||
|
||||
if (low == start && !allowEmpty) {
|
||||
low++;
|
||||
}
|
||||
return LineBreakResult.sameIndex(low, LineBreakType.prohibited);
|
||||
}
|
||||
|
||||
double _measure(int start, int end) {
|
||||
assert(_currentSpan != null);
|
||||
final FlatTextSpan span = _currentSpan!;
|
||||
|
||||
@ -12,8 +12,6 @@ import 'package:ui/ui.dart' as ui;
|
||||
import 'layout_service_helper.dart';
|
||||
|
||||
const bool skipForceBreak = true;
|
||||
const bool skipOverflow = true;
|
||||
const bool skipMaxLines = true;
|
||||
const bool skipTextAlign = true;
|
||||
const bool skipWordSpacing = true;
|
||||
|
||||
@ -443,7 +441,7 @@ void testMain() async {
|
||||
expectLines(paragraph, [
|
||||
l('...', 0, 0, hardBreak: false, width: 30.0, left: 0.0),
|
||||
]);
|
||||
}, skip: skipOverflow);
|
||||
});
|
||||
|
||||
test('respects max lines', () {
|
||||
final EngineParagraphStyle maxlinesStyle = EngineParagraphStyle(
|
||||
@ -491,7 +489,7 @@ void testMain() async {
|
||||
l('AAA ', 0, 4, hardBreak: false, width: 30.0, left: 0.0),
|
||||
l('AAAAA', 4, 9, hardBreak: false, width: 50.0, left: 0.0),
|
||||
]);
|
||||
}, skip: skipMaxLines);
|
||||
});
|
||||
|
||||
test('respects text overflow and max lines combined', () {
|
||||
final EngineParagraphStyle onelineStyle = EngineParagraphStyle(
|
||||
@ -573,7 +571,7 @@ void testMain() async {
|
||||
l('abcdef', 0, 6, hardBreak: false, width: 60.0, left: 0.0),
|
||||
l('g h...', 6, 9, hardBreak: false, width: 60.0, left: 0.0),
|
||||
]);
|
||||
}, skip: skipOverflow || skipMaxLines);
|
||||
});
|
||||
|
||||
test('handles textAlign', () {
|
||||
CanvasParagraph paragraph;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user