Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 9e45508

Browse files
authored
[web] Multiple fixes to text layout and line breaks (#28642)
1 parent 7e94275 commit 9e45508

File tree

9 files changed

+276
-72
lines changed

9 files changed

+276
-72
lines changed

lib/web_ui/dev/goldens_lock.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
repository: https://github.com/flutter/goldens.git
2-
revision: 96b97105f7dc8ebae5babdf22b809ba3980061f6
2+
revision: 9c36f57f1a673a7ab444f4f20df16601dde15335

lib/web_ui/lib/src/engine/text/layout_service.dart

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ class TextLayoutService {
121121
// *** THE MAIN MEASUREMENT PART *** //
122122
// ********************************* //
123123

124-
final ParagraphSpan span = paragraph.spans[spanIndex];
124+
ParagraphSpan span = paragraph.spans[spanIndex];
125125

126126
if (span is PlaceholderSpan) {
127127
if (currentLine.widthIncludingSpace + span.width <= constraints.width) {
@@ -143,9 +143,6 @@ class TextLayoutService {
143143
currentLine.getAdditionalWidthTo(nextBreak.lineBreak);
144144

145145
if (currentLine.width + additionalWidth <= constraints.width) {
146-
// TODO(mdebbar): Handle the case when `nextBreak` is just a span end
147-
// that shouldn't extend the line yet.
148-
149146
// The line can extend to `nextBreak` without overflowing.
150147
currentLine.extendTo(nextBreak);
151148
if (nextBreak.type == LineBreakType.mandatory) {
@@ -168,8 +165,8 @@ class TextLayoutService {
168165
);
169166
lines.add(currentLine.build(ellipsis: ellipsis));
170167
break;
171-
} else if (currentLine.isEmpty) {
172-
// The current line is still empty, which means we are dealing
168+
} else if (currentLine.isNotBreakable) {
169+
// The entire line is unbreakable, which means we are dealing
173170
// with a single block of text that doesn't fit in a single line.
174171
// We need to force-break it without adding an ellipsis.
175172

@@ -178,6 +175,16 @@ class TextLayoutService {
178175
currentLine = currentLine.nextLine();
179176
} else {
180177
// Normal line break.
178+
currentLine.revertToLastBreakOpportunity();
179+
// If a revert had occurred in the line, we need to revert the span
180+
// index accordingly.
181+
//
182+
// If no revert occurred, then `revertedToSpan` will be equal to
183+
// `span` and the following while loop won't do anything.
184+
final ParagraphSpan revertedToSpan = currentLine.lastSegment.span;
185+
while (span != revertedToSpan) {
186+
span = paragraph.spans[--spanIndex];
187+
}
181188
lines.add(currentLine.build());
182189
currentLine = currentLine.nextLine();
183190
}
@@ -815,7 +822,7 @@ class LineBuilder {
815822
required this.start,
816823
required this.lineNumber,
817824
required this.accumulatedHeight,
818-
}) : end = start;
825+
}) : _end = start;
819826

820827
/// Creates a [LineBuilder] for the first line in a paragraph.
821828
factory LineBuilder.first(
@@ -846,7 +853,14 @@ class LineBuilder {
846853
final double accumulatedHeight;
847854

848855
/// The index of the end of the line so far.
849-
LineBreakResult end;
856+
LineBreakResult get end => _end;
857+
LineBreakResult _end;
858+
set end(LineBreakResult value) {
859+
if (value.type != LineBreakType.prohibited) {
860+
isBreakable = true;
861+
}
862+
_end = value;
863+
}
850864

851865
/// The width of the line so far, excluding trailing white space.
852866
double width = 0.0;
@@ -869,6 +883,15 @@ class LineBuilder {
869883
/// The last segment in this line.
870884
LineSegment get lastSegment => _segments.last;
871885

886+
/// Returns true if there is at least one break opportunity in the line.
887+
bool isBreakable = false;
888+
889+
/// Returns true if there's no break opportunity in the line.
890+
bool get isNotBreakable => !isBreakable;
891+
892+
/// Whether the end of this line is a prohibited break.
893+
bool get isEndProhibited => end.type == LineBreakType.prohibited;
894+
872895
bool get isEmpty => _segments.isEmpty;
873896
bool get isNotEmpty => _segments.isNotEmpty;
874897

@@ -1026,6 +1049,8 @@ class LineBuilder {
10261049
boxDirection: _currentBoxDirection,
10271050
));
10281051
_currentBoxStartOffset = widthIncludingSpace;
1052+
// Breaking is always allowed after a placeholder.
1053+
isBreakable = true;
10291054
}
10301055

10311056
/// Creates a new segment to be appended to the end of this line.
@@ -1099,6 +1124,15 @@ class LineBuilder {
10991124
}
11001125
}
11011126

1127+
// Now let's fixes boxes if they need fixing.
1128+
//
1129+
// If we popped a segment of an already created box, we should pop the box
1130+
// too.
1131+
if (_currentBoxStart.index > poppedSegment.start.index) {
1132+
final RangeBox poppedBox = _boxes.removeLast();
1133+
_currentBoxStartOffset -= poppedBox.width;
1134+
}
1135+
11021136
return poppedSegment;
11031137
}
11041138

@@ -1182,6 +1216,22 @@ class LineBuilder {
11821216
extendTo(nextBreak.copyWithIndex(breakingPoint));
11831217
}
11841218

1219+
/// Looks for the last break opportunity in the line and reverts the line to
1220+
/// that point.
1221+
///
1222+
/// If the line already ends with a break opportunity, this method does
1223+
/// nothing.
1224+
void revertToLastBreakOpportunity() {
1225+
assert(isBreakable);
1226+
while (isEndProhibited) {
1227+
_popSegment();
1228+
}
1229+
// Make sure the line is not empty and still breakable after popping a few
1230+
// segments.
1231+
assert(isNotEmpty);
1232+
assert(isBreakable);
1233+
}
1234+
11851235
LineBreakResult get _currentBoxStart {
11861236
if (_boxes.isEmpty) {
11871237
return start;
@@ -1360,13 +1410,21 @@ class LineBuilder {
13601410
return cumulativeWidth;
13611411
}
13621412

1413+
LineBreakResult? _cachedNextBreak;
1414+
13631415
/// Finds the next line break after the end of this line.
13641416
DirectionalPosition findNextBreak() {
1417+
LineBreakResult? nextBreak = _cachedNextBreak;
13651418
final String text = paragraph.toPlainText();
1366-
final int maxEnd = spanometer.currentSpan.end;
1367-
final LineBreakResult result = nextLineBreak(text, end.index, maxEnd: maxEnd);
1419+
// Don't recompute the `nextBreak` until the line has reached the previously
1420+
// computed `nextBreak`.
1421+
if (nextBreak == null || end.index >= nextBreak.index) {
1422+
final int maxEnd = spanometer.currentSpan.end;
1423+
nextBreak = nextLineBreak(text, end.index, maxEnd: maxEnd);
1424+
_cachedNextBreak = nextBreak;
1425+
}
13681426
// The current end of the line is the beginning of the next block.
1369-
return getDirectionalBlockEnd(text, end, result);
1427+
return getDirectionalBlockEnd(text, end, nextBreak);
13701428
}
13711429

13721430
/// Creates a new [LineBuilder] to build the next line in the paragraph.

lib/web_ui/lib/src/engine/text/line_breaker.dart

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:math' as math;
6+
57
import 'package:ui/ui.dart' as ui;
68

79
import '../util.dart';
@@ -153,7 +155,7 @@ bool _hasEastAsianWidthFWH(int charCode) {
153155

154156
/// Finds the next line break in the given [text] starting from [index].
155157
///
156-
/// Wethink about indices as pointing between characters, and they go all the
158+
/// We think about indices as pointing between characters, and they go all the
157159
/// way from 0 to the string length. For example, here are the indices for the
158160
/// string "foo bar":
159161
///
@@ -170,6 +172,19 @@ bool _hasEastAsianWidthFWH(int charCode) {
170172
/// * https://www.unicode.org/reports/tr14/tr14-45.html#Algorithm
171173
/// * https://www.unicode.org/Public/11.0.0/ucd/LineBreak.txt
172174
LineBreakResult nextLineBreak(String text, int index, {int? maxEnd}) {
175+
final LineBreakResult unsafeResult = _unsafeNextLineBreak(text, index, maxEnd: maxEnd);
176+
if (maxEnd != null && unsafeResult.index > maxEnd) {
177+
return LineBreakResult(
178+
maxEnd,
179+
math.min(maxEnd, unsafeResult.indexWithoutTrailingNewlines),
180+
math.min(maxEnd, unsafeResult.indexWithoutTrailingSpaces),
181+
LineBreakType.prohibited,
182+
);
183+
}
184+
return unsafeResult;
185+
}
186+
187+
LineBreakResult _unsafeNextLineBreak(String text, int index, {int? maxEnd}) {
173188
int? codePoint = getCodePoint(text, index);
174189
LineCharProperty curr = lineLookup.findForChar(codePoint);
175190

@@ -208,11 +223,11 @@ LineBreakResult nextLineBreak(String text, int index, {int? maxEnd}) {
208223
// Always break at the end of text.
209224
// LB3: ! eot
210225
while (index < text.length) {
211-
if (index == maxEnd) {
226+
if (maxEnd != null && index > maxEnd) {
212227
return LineBreakResult(
213-
index,
214-
lastNonNewlineIndex,
215-
lastNonSpaceIndex,
228+
maxEnd,
229+
math.min(maxEnd, lastNonNewlineIndex),
230+
math.min(maxEnd, lastNonSpaceIndex),
216231
LineBreakType.prohibited,
217232
);
218233
}
@@ -389,26 +404,34 @@ LineBreakResult nextLineBreak(String text, int index, {int? maxEnd}) {
389404
// × EX
390405
// × IS
391406
// × SY
392-
if (curr == LineCharProperty.CL ||
393-
curr == LineCharProperty.CP ||
394-
curr == LineCharProperty.EX ||
395-
curr == LineCharProperty.IS ||
396-
curr == LineCharProperty.SY) {
407+
//
408+
// The above is a quote from unicode.org. In our implementation, we did the
409+
// following modification: When there are spaces present, we consider it a
410+
// line break opportunity.
411+
if (prev1 != LineCharProperty.SP &&
412+
(curr == LineCharProperty.CL ||
413+
curr == LineCharProperty.CP ||
414+
curr == LineCharProperty.EX ||
415+
curr == LineCharProperty.IS ||
416+
curr == LineCharProperty.SY)) {
397417
continue;
398418
}
399419

400420
// Do not break after ‘[’, even after spaces.
401421
// LB14: OP SP* ×
402-
if (prev1 == LineCharProperty.OP ||
403-
baseOfSpaceSequence == LineCharProperty.OP) {
422+
//
423+
// The above is a quote from unicode.org. In our implementation, we did the
424+
// following modification: Allow breaks when there are spaces.
425+
if (prev1 == LineCharProperty.OP) {
404426
continue;
405427
}
406428

407429
// Do not break within ‘”[’, even with intervening spaces.
408430
// LB15: QU SP* × OP
409-
if ((prev1 == LineCharProperty.QU ||
410-
baseOfSpaceSequence == LineCharProperty.QU) &&
411-
curr == LineCharProperty.OP) {
431+
//
432+
// The above is a quote from unicode.org. In our implementation, we did the
433+
// following modification: Allow breaks when there are spaces.
434+
if (prev1 == LineCharProperty.QU && curr == LineCharProperty.OP) {
412435
continue;
413436
}
414437

lib/web_ui/lib/src/engine/text/paragraph.dart

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -268,19 +268,19 @@ class EngineParagraphStyle implements ui.ParagraphStyle {
268268
double? get lineHeight {
269269
// TODO(mdebbar): Implement proper support for strut styles.
270270
// https://github.com/flutter/flutter/issues/32243
271-
if (_strutStyle == null ||
272-
_strutStyle!._height == null ||
273-
_strutStyle!._height == 0) {
271+
final EngineStrutStyle? strutStyle = _strutStyle;
272+
final double? strutHeight = strutStyle?._height;
273+
if (strutStyle == null || strutHeight == null || strutHeight == 0) {
274274
// When there's no strut height, always use paragraph style height.
275275
return height;
276276
}
277-
if (_strutStyle!._forceStrutHeight == true) {
277+
if (strutStyle._forceStrutHeight == true) {
278278
// When strut height is forced, ignore paragraph style height.
279-
return _strutStyle!._height;
279+
return strutHeight;
280280
}
281281
// In this case, strut height acts as a minimum height for all parts of the
282282
// paragraph. So we take the max of strut height and paragraph style height.
283-
return math.max(_strutStyle!._height!, height ?? 0.0);
283+
return math.max(strutHeight, height ?? 0.0);
284284
}
285285

286286
@override
@@ -324,6 +324,8 @@ class EngineParagraphStyle implements ui.ParagraphStyle {
324324
@override
325325
String toString() {
326326
if (assertionsEnabled) {
327+
final double? fontSize = this.fontSize;
328+
final double? height = this.height;
327329
return 'ParagraphStyle('
328330
'textAlign: ${textAlign ?? "unspecified"}, '
329331
'textDirection: ${textDirection ?? "unspecified"}, '
@@ -332,8 +334,8 @@ class EngineParagraphStyle implements ui.ParagraphStyle {
332334
'maxLines: ${maxLines ?? "unspecified"}, '
333335
'textHeightBehavior: ${_textHeightBehavior ?? "unspecified"}, '
334336
'fontFamily: ${fontFamily ?? "unspecified"}, '
335-
'fontSize: ${fontSize != null ? fontSize!.toStringAsFixed(1) : "unspecified"}, '
336-
'height: ${height != null ? "${height!.toStringAsFixed(1)}x" : "unspecified"}, '
337+
'fontSize: ${fontSize != null ? fontSize.toStringAsFixed(1) : "unspecified"}, '
338+
'height: ${height != null ? "${height.toStringAsFixed(1)}x" : "unspecified"}, '
337339
'ellipsis: ${ellipsis != null ? "\"$ellipsis\"" : "unspecified"}, '
338340
'locale: ${locale ?? "unspecified"}'
339341
')';
@@ -533,6 +535,9 @@ class EngineTextStyle implements ui.TextStyle {
533535
@override
534536
String toString() {
535537
if (assertionsEnabled) {
538+
final List<String>? fontFamilyFallback = this.fontFamilyFallback;
539+
final double? fontSize = this.fontSize;
540+
final double? height = this.height;
536541
return 'TextStyle('
537542
'color: ${color ?? "unspecified"}, '
538543
'decoration: ${decoration ?? "unspecified"}, '
@@ -543,11 +548,11 @@ class EngineTextStyle implements ui.TextStyle {
543548
'fontStyle: ${fontStyle ?? "unspecified"}, '
544549
'textBaseline: ${textBaseline ?? "unspecified"}, '
545550
'fontFamily: ${isFontFamilyProvided && fontFamily != '' ? fontFamily : "unspecified"}, '
546-
'fontFamilyFallback: ${isFontFamilyProvided && fontFamilyFallback != null && fontFamilyFallback!.isNotEmpty ? fontFamilyFallback : "unspecified"}, '
547-
'fontSize: ${fontSize != null ? fontSize!.toStringAsFixed(1) : "unspecified"}, '
551+
'fontFamilyFallback: ${isFontFamilyProvided && fontFamilyFallback != null && fontFamilyFallback.isNotEmpty ? fontFamilyFallback : "unspecified"}, '
552+
'fontSize: ${fontSize != null ? fontSize.toStringAsFixed(1) : "unspecified"}, '
548553
'letterSpacing: ${letterSpacing != null ? "${letterSpacing}x" : "unspecified"}, '
549554
'wordSpacing: ${wordSpacing != null ? "${wordSpacing}x" : "unspecified"}, '
550-
'height: ${height != null ? "${height!.toStringAsFixed(1)}x" : "unspecified"}, '
555+
'height: ${height != null ? "${height.toStringAsFixed(1)}x" : "unspecified"}, '
551556
'locale: ${locale ?? "unspecified"}, '
552557
'background: ${background ?? "unspecified"}, '
553558
'foreground: ${foreground ?? "unspecified"}, '
@@ -722,8 +727,9 @@ void applyTextStyleToElement({
722727
if (style.height != null) {
723728
cssStyle.lineHeight = '${style.height}';
724729
}
725-
if (style.fontSize != null) {
726-
cssStyle.fontSize = '${style.fontSize!.floor()}px';
730+
final double? fontSize = style.fontSize;
731+
if (fontSize != null) {
732+
cssStyle.fontSize = '${fontSize.floor()}px';
727733
}
728734
if (style.fontWeight != null) {
729735
cssStyle.fontWeight = fontWeightToCss(style.fontWeight);
@@ -748,8 +754,9 @@ void applyTextStyleToElement({
748754
if (style.decoration != null) {
749755
updateDecoration = true;
750756
}
751-
if (style.shadows != null) {
752-
cssStyle.textShadow = _shadowListToCss(style.shadows!);
757+
final List<ui.Shadow>? shadows = style.shadows;
758+
if (shadows != null) {
759+
cssStyle.textShadow = _shadowListToCss(shadows);
753760
}
754761

755762
if (updateDecoration) {

0 commit comments

Comments
 (0)