diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart index 663969832fc..b0e368a77ba 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text/layout_service.dart @@ -89,12 +89,13 @@ class TextLayoutService { if (currentLine.end.isHard) { if (currentLine.isNotEmpty) { lines.add(currentLine.build()); + if (currentLine.end.type != LineBreakType.endOfText) { + currentLine = currentLine.nextLine(); + } } if (currentLine.end.type == LineBreakType.endOfText) { break; - } else { - currentLine = currentLine.nextLine(); } } @@ -400,13 +401,27 @@ class LineBuilder { }) { if (ellipsis == null) { final double availableWidth = maxWidth - widthIncludingSpace; - final LineBreakResult breakingPoint = spanometer.forceBreak( + final int breakingPoint = spanometer.forceBreak( end.index, nextBreak.indexWithoutTrailingSpaces, availableWidth: availableWidth, allowEmpty: allowEmpty, ); - extendTo(breakingPoint); + + // This condition can be true in the following case: + // 1. Next break is only one character away, with zero or many spaces. AND + // 2. There isn't enough width to fit the single character. AND + // 3. `allowEmpty` is false. + if (breakingPoint == nextBreak.indexWithoutTrailingSpaces) { + // In this case, we just extend to `nextBreak` instead of creating a new + // artifical break. It's safe (and better) to do so, because we don't + // want the trailing white space to go to the next line. + extendTo(nextBreak); + } else { + extendTo( + LineBreakResult.sameIndex(breakingPoint, LineBreakType.prohibited), + ); + } return; } @@ -428,20 +443,20 @@ class LineBuilder { // 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) { + while (_segments.isNotEmpty && widthIncludingSpace > availableWidth) { segmentToBreak = _popSegment(); } spanometer.currentSpan = segmentToBreak.span as FlatTextSpan; final double availableWidthForSegment = availableWidth - widthIncludingSpace; - final LineBreakResult breakingPoint = spanometer.forceBreak( + final int breakingPoint = spanometer.forceBreak( segmentToBreak.start.index, - segmentToBreak.end.indexWithoutTrailingSpaces, + segmentToBreak.end.index, availableWidth: availableWidthForSegment, allowEmpty: allowEmpty, ); - extendTo(breakingPoint); + extendTo(LineBreakResult.sameIndex(breakingPoint, LineBreakType.prohibited)); } /// Builds the [EngineLineMetrics] instance that represents this line. @@ -544,7 +559,19 @@ class Spanometer { return _measureSubstring(context, text, 0, text.length); } - LineBreakResult forceBreak( + /// In a continuous, unbreakable block of text from [start] to [end], finds + /// the point where text should be broken to fit in the given [availableWidth]. + /// + /// The [start] and [end] indices have to be within the same text span. + /// + /// When [allowEmpty] is true, the result is guaranteed to be at least one + /// character after [start]. But if [allowEmpty] is false and there isn't + /// enough [availableWidth] to fit the first character, then [start] is + /// returned. + /// + /// See also: + /// - [LineBuilder.forceBreak]. + int forceBreak( int start, int end, { required double availableWidth, @@ -559,8 +586,7 @@ class Spanometer { assert(end >= span.start && end <= span.end); if (availableWidth <= 0.0) { - return LineBreakResult.sameIndex( - allowEmpty ? start : start + 1, LineBreakType.prohibited); + return allowEmpty ? start : start + 1; } int low = start; @@ -580,7 +606,7 @@ class Spanometer { if (low == start && !allowEmpty) { low++; } - return LineBreakResult.sameIndex(low, LineBreakType.prohibited); + return low; } double _measure(int start, int end) { diff --git a/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart b/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart index 83138259bc5..1bafe38f511 100644 --- a/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart +++ b/engine/src/flutter/lib/web_ui/test/text/layout_service_plain_test.dart @@ -11,7 +11,6 @@ import 'package:ui/ui.dart' as ui; import 'layout_service_helper.dart'; -const bool skipForceBreak = true; const bool skipTextAlign = true; const bool skipWordSpacing = true; @@ -147,6 +146,17 @@ void testMain() async { l('k lm', 10, 14, hardBreak: true, width: 40.0, left: 0.0), ]); + // Constraints enough only for "abcdef" but not for the trailing space. + paragraph = plain(ahemStyle, 'abcdef gh')..layout(constrain(60.0)); + expect(paragraph.maxIntrinsicWidth, 90); + expect(paragraph.minIntrinsicWidth, 60); + expect(paragraph.width, 60); + // expect(paragraph.height, 20); + expectLines(paragraph, [ + l('abcdef ', 0, 7, hardBreak: false, width: 60.0, left: 0.0), + l('gh', 7, 9, hardBreak: true, width: 20.0, left: 0.0), + ]); + // Constraints aren't enough even for a single character. In this case, // we show a minimum of one character per line. paragraph = plain(ahemStyle, 'AA')..layout(constrain(8.0)); @@ -183,7 +193,7 @@ void testMain() async { l('A', 2, 4, hardBreak: true, width: 10.0, left: 0.0), l('', 4, 4, hardBreak: true, width: 0.0, left: 0.0), ]); - }, skip: skipForceBreak); + }); test('uses multi-line for text that contains new-line', () { final CanvasParagraph paragraph = plain(ahemStyle, '12\n34') @@ -315,16 +325,14 @@ void testMain() async { l('defg', 8, 12, hardBreak: true, width: 40.0, left: 0.0), ]); - if (!skipForceBreak) { - // Very long text. - paragraph = plain(ahemStyle, 'AAAAAAAAAAAA')..layout(constrain(50.0)); - expect(paragraph.minIntrinsicWidth, 120); - expectLines(paragraph, [ - l('AAAAA', 0, 5, hardBreak: false, width: 50.0, left: 0.0), - l('AAAAA', 5, 10, hardBreak: false, width: 50.0, left: 0.0), - l('AA', 10, 12, hardBreak: true, width: 20.0, left: 0.0), - ]); - } + // Very long text. + paragraph = plain(ahemStyle, 'AAAAAAAAAAAA')..layout(constrain(50.0)); + expect(paragraph.minIntrinsicWidth, 120); + expectLines(paragraph, [ + l('AAAAA', 0, 5, hardBreak: false, width: 50.0, left: 0.0), + l('AAAAA', 5, 10, hardBreak: false, width: 50.0, left: 0.0), + l('AA', 10, 12, hardBreak: true, width: 20.0, left: 0.0), + ]); }); test('maxIntrinsicWidth', () { @@ -372,16 +380,14 @@ void testMain() async { l('def ', 5, 11, hardBreak: true, width: 30.0, left: 0.0), ]); - if (!skipForceBreak) { - // Very long text. - paragraph = plain(ahemStyle, 'AAAAAAAAAAAA')..layout(constrain(50.0)); - expect(paragraph.maxIntrinsicWidth, 120); - expectLines(paragraph, [ - l('AAAAA', 0, 5, hardBreak: false, width: 50.0, left: 0.0), - l('AAAAA', 5, 10, hardBreak: false, width: 50.0, left: 0.0), - l('AA', 10, 12, hardBreak: true, width: 20.0, left: 0.0), - ]); - } + // Very long text. + paragraph = plain(ahemStyle, 'AAAAAAAAAAAA')..layout(constrain(50.0)); + expect(paragraph.maxIntrinsicWidth, 120); + expectLines(paragraph, [ + l('AAAAA', 0, 5, hardBreak: false, width: 50.0, left: 0.0), + l('AAAAA', 5, 10, hardBreak: false, width: 50.0, left: 0.0), + l('AA', 10, 12, hardBreak: true, width: 20.0, left: 0.0), + ]); }); test('respects text overflow', () { @@ -418,6 +424,17 @@ void testMain() async { l('AA...', 4, 6, hardBreak: false, width: 50.0, left: 0.0), ]); + // Constraints only enough to fit "AA" with the ellipsis, but not the + // trailing white space. + final CanvasParagraph trailingSpace = plain(overflowStyle, 'AA AAA') + ..layout(constrain(50.0)); + expect(trailingSpace.minIntrinsicWidth, 30); + expect(trailingSpace.maxIntrinsicWidth, 60); + // expect(trailingSpace.height, 10); + expectLines(trailingSpace, [ + l('AA...', 0, 2, hardBreak: false, width: 50.0, left: 0.0), + ]); + // Tiny constraints. final CanvasParagraph paragraph = plain(overflowStyle, 'AAAA') ..layout(constrain(30.0));