blob: 0c48c0f5eeb928fcaecf946d6146abb9fdfbbe09 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:ui/ui.dart' as ui;
import '../util.dart';
import 'canvas_paragraph.dart';
import 'fragmenter.dart';
import 'layout_service.dart';
import 'line_breaker.dart';
import 'paragraph.dart';
import 'text_direction.dart';
/// Splits [text] into fragments that are ready to be laid out by
/// [TextLayoutService].
///
/// This fragmenter takes into account line breaks, directionality and styles.
class LayoutFragmenter extends TextFragmenter {
const LayoutFragmenter(super.text, this.paragraphSpans);
final List<ParagraphSpan> paragraphSpans;
@override
List<LayoutFragment> fragment() {
final List<LayoutFragment> fragments = <LayoutFragment>[];
int fragmentStart = 0;
final Iterator<LineBreakFragment> lineBreakFragments = LineBreakFragmenter(text).fragment().iterator..moveNext();
final Iterator<BidiFragment> bidiFragments = BidiFragmenter(text).fragment().iterator..moveNext();
final Iterator<ParagraphSpan> spans = paragraphSpans.iterator..moveNext();
LineBreakFragment currentLineBreakFragment = lineBreakFragments.current;
BidiFragment currentBidiFragment = bidiFragments.current;
ParagraphSpan currentSpan = spans.current;
while (true) {
final int fragmentEnd = math.min(
currentLineBreakFragment.end,
math.min(
currentBidiFragment.end,
currentSpan.end,
),
);
final int distanceFromLineBreak = currentLineBreakFragment.end - fragmentEnd;
final LineBreakType lineBreakType = distanceFromLineBreak == 0
? currentLineBreakFragment.type
: LineBreakType.prohibited;
final int trailingNewlines = currentLineBreakFragment.trailingNewlines - distanceFromLineBreak;
final int trailingSpaces = currentLineBreakFragment.trailingSpaces - distanceFromLineBreak;
final int fragmentLength = fragmentEnd - fragmentStart;
fragments.add(LayoutFragment(
fragmentStart,
fragmentEnd,
lineBreakType,
currentBidiFragment.textDirection,
currentBidiFragment.fragmentFlow,
currentSpan,
trailingNewlines: clampInt(trailingNewlines, 0, fragmentLength),
trailingSpaces: clampInt(trailingSpaces, 0, fragmentLength),
));
fragmentStart = fragmentEnd;
bool moved = false;
if (currentLineBreakFragment.end == fragmentEnd) {
if (lineBreakFragments.moveNext()) {
moved = true;
currentLineBreakFragment = lineBreakFragments.current;
}
}
if (currentBidiFragment.end == fragmentEnd) {
if (bidiFragments.moveNext()) {
moved = true;
currentBidiFragment = bidiFragments.current;
}
}
if (currentSpan.end == fragmentEnd) {
if (spans.moveNext()) {
moved = true;
currentSpan = spans.current;
}
}
// Once we reached the end of all fragments, exit the loop.
if (!moved) {
break;
}
}
return fragments;
}
}
abstract class _CombinedFragment extends TextFragment {
_CombinedFragment(
super.start,
super.end,
this.type,
this._textDirection,
this.fragmentFlow,
this.span, {
required this.trailingNewlines,
required this.trailingSpaces,
}) : assert(trailingNewlines >= 0),
assert(trailingSpaces >= trailingNewlines);
final LineBreakType type;
ui.TextDirection? get textDirection => _textDirection;
ui.TextDirection? _textDirection;
final FragmentFlow fragmentFlow;
final ParagraphSpan span;
final int trailingNewlines;
final int trailingSpaces;
@override
int get hashCode => Object.hash(
start,
end,
type,
textDirection,
fragmentFlow,
span,
trailingNewlines,
trailingSpaces,
);
@override
bool operator ==(Object other) {
return other is LayoutFragment &&
other.start == start &&
other.end == end &&
other.type == type &&
other.textDirection == textDirection &&
other.fragmentFlow == fragmentFlow &&
other.span == span &&
other.trailingNewlines == trailingNewlines &&
other.trailingSpaces == trailingSpaces;
}
}
class LayoutFragment extends _CombinedFragment with _FragmentMetrics, _FragmentPosition, _FragmentBox {
LayoutFragment(
super.start,
super.end,
super.type,
super.textDirection,
super.fragmentFlow,
super.span, {
required super.trailingNewlines,
required super.trailingSpaces,
});
int get length => end - start;
bool get isSpaceOnly => length == trailingSpaces;
bool get isPlaceholder => span is PlaceholderSpan;
bool get isBreak => type != LineBreakType.prohibited;
bool get isHardBreak => type == LineBreakType.mandatory || type == LineBreakType.endOfText;
EngineTextStyle get style => span.style;
/// Returns the substring from [paragraph] that corresponds to this fragment,
/// excluding new line characters.
String getText(CanvasParagraph paragraph) {
return paragraph.plainText.substring(start, end - trailingNewlines);
}
/// Splits this fragment into two fragments with the split point being the
/// given [index].
// TODO(mdebbar): If we ever get multiple return values in Dart, we should use it!
// See: https://github.com/dart-lang/language/issues/68
List<LayoutFragment?> split(int index) {
assert(start <= index);
assert(index <= end);
if (start == index) {
return <LayoutFragment?>[null, this];
}
if (end == index) {
return <LayoutFragment?>[this, null];
}
// The length of the second fragment after the split.
final int secondLength = end - index;
// Trailing spaces/new lines go to the second fragment. Any left over goes
// to the first fragment.
final int secondTrailingNewlines = math.min(trailingNewlines, secondLength);
final int secondTrailingSpaces = math.min(trailingSpaces, secondLength);
return <LayoutFragment>[
LayoutFragment(
start,
index,
LineBreakType.prohibited,
textDirection,
fragmentFlow,
span,
trailingNewlines: trailingNewlines - secondTrailingNewlines,
trailingSpaces: trailingSpaces - secondTrailingSpaces,
),
LayoutFragment(
index,
end,
type,
textDirection,
fragmentFlow,
span,
trailingNewlines: secondTrailingNewlines,
trailingSpaces: secondTrailingSpaces,
),
];
}
@override
String toString() {
return '$LayoutFragment($start, $end, $type, $textDirection)';
}
}
mixin _FragmentMetrics on _CombinedFragment {
late Spanometer _spanometer;
/// The rise from the baseline as calculated from the font and style for this text.
double get ascent => _ascent;
late double _ascent;
/// The drop from the baseline as calculated from the font and style for this text.
double get descent => _descent;
late double _descent;
/// The width of the measured text, not including trailing spaces.
double get widthExcludingTrailingSpaces => _widthExcludingTrailingSpaces;
late double _widthExcludingTrailingSpaces;
/// The width of the measured text, including any trailing spaces.
double get widthIncludingTrailingSpaces => _widthIncludingTrailingSpaces + _extraWidthForJustification;
late double _widthIncludingTrailingSpaces;
double _extraWidthForJustification = 0.0;
/// The total height as calculated from the font and style for this text.
double get height => ascent + descent;
double get widthOfTrailingSpaces => widthIncludingTrailingSpaces - widthExcludingTrailingSpaces;
/// Set measurement values for the fragment.
void setMetrics(Spanometer spanometer, {
required double ascent,
required double descent,
required double widthExcludingTrailingSpaces,
required double widthIncludingTrailingSpaces,
}) {
_spanometer = spanometer;
_ascent = ascent;
_descent = descent;
_widthExcludingTrailingSpaces = widthExcludingTrailingSpaces;
_widthIncludingTrailingSpaces = widthIncludingTrailingSpaces;
}
}
/// Encapsulates positioning of the fragment relative to the line.
///
/// The coordinates are all relative to the line it belongs to. For example,
/// [left] is the distance from the left edge of the line to the left edge of
/// the fragment.
///
/// This is what the various measurements/coordinates look like for a fragment
/// in an LTR paragraph:
///
/// *------------------------line.width-----------------*
/// *---width----*
/// ┌─────────────────┬────────────┬────────────────────┐
/// │ │--FRAGMENT--│ │
/// └─────────────────┴────────────┴────────────────────┘
/// *---startOffset---*
/// *------left-------*
/// *--------endOffset-------------*
/// *----------right---------------*
///
///
/// And in an RTL paragraph, [startOffset] and [endOffset] are flipped because
/// the line starts from the right. Here's what they look like:
///
/// *------------------------line.width-----------------*
/// *---width----*
/// ┌─────────────────┬────────────┬────────────────────┐
/// │ │--FRAGMENT--│ │
/// └─────────────────┴────────────┴────────────────────┘
/// *----startOffset-----*
/// *------left-------*
/// *-----------endOffset-------------*
/// *----------right---------------*
///
mixin _FragmentPosition on _CombinedFragment, _FragmentMetrics {
/// The distance from the beginning of the line to the beginning of the fragment.
double get startOffset => _startOffset;
late double _startOffset;
/// The width of the line that contains this fragment.
late ParagraphLine line;
/// The distance from the beginning of the line to the end of the fragment.
double get endOffset => startOffset + widthIncludingTrailingSpaces;
/// The distance from the left edge of the line to the left edge of the fragment.
double get left => line.textDirection == ui.TextDirection.ltr
? startOffset
: line.width - endOffset;
/// The distance from the left edge of the line to the right edge of the fragment.
double get right => line.textDirection == ui.TextDirection.ltr
? endOffset
: line.width - startOffset;
/// Set the horizontal position of this fragment relative to the [line] that
/// contains it.
void setPosition({
required double startOffset,
required ui.TextDirection textDirection,
}) {
_startOffset = startOffset;
_textDirection ??= textDirection;
}
/// Adjust the width of this fragment for paragraph justification.
void justifyTo({required double paragraphWidth}) {
// Only justify this fragment if it's not a trailing space in the line.
if (end > line.endIndex - line.trailingSpaces) {
// Don't justify fragments that are part of trailing spaces of the line.
return;
}
if (trailingSpaces == 0) {
// If this fragment has no spaces, there's nothing to justify.
return;
}
final double justificationTotal = paragraphWidth - line.width;
final double justificationPerSpace = justificationTotal / line.nonTrailingSpaces;
_extraWidthForJustification = justificationPerSpace * trailingSpaces;
}
}
/// Encapsulates calculations related to the bounding box of the fragment
/// relative to the paragraph.
mixin _FragmentBox on _CombinedFragment, _FragmentMetrics, _FragmentPosition {
double get top => line.baseline - ascent;
double get bottom => line.baseline + descent;
late final ui.TextBox _textBoxIncludingTrailingSpaces = ui.TextBox.fromLTRBD(
line.left + left,
top,
line.left + right,
bottom,
textDirection!,
);
/// Whether or not the trailing spaces of this fragment are part of trailing
/// spaces of the line containing the fragment.
bool get _isPartOfTrailingSpacesInLine => end > line.endIndex - line.trailingSpaces;
/// Returns a [ui.TextBox] for the purpose of painting this fragment.
///
/// The coordinates of the resulting [ui.TextBox] are relative to the
/// paragraph, not to the line.
///
/// Trailing spaces in each line aren't painted on the screen, so they are
/// excluded from the resulting text box.
ui.TextBox toPaintingTextBox() {
if (_isPartOfTrailingSpacesInLine) {
// For painting, we exclude the width of trailing spaces from the box.
return textDirection! == ui.TextDirection.ltr
? ui.TextBox.fromLTRBD(
line.left + left,
top,
line.left + right - widthOfTrailingSpaces,
bottom,
textDirection!,
)
: ui.TextBox.fromLTRBD(
line.left + left + widthOfTrailingSpaces,
top,
line.left + right,
bottom,
textDirection!,
);
}
return _textBoxIncludingTrailingSpaces;
}
/// Returns a [ui.TextBox] representing this fragment.
///
/// The coordinates of the resulting [ui.TextBox] are relative to the
/// paragraph, not to the line.
///
/// As opposed to [toPaintingTextBox], the resulting text box from this method
/// includes trailing spaces of the fragment.
ui.TextBox toTextBox({
int? start,
int? end,
}) {
start ??= this.start;
end ??= this.end;
if (start <= this.start && end >= this.end - trailingNewlines) {
return _textBoxIncludingTrailingSpaces;
}
return _intersect(start, end);
}
/// Performs the intersection of this fragment with the range given by [start] and
/// [end] indices, and returns a [ui.TextBox] representing that intersection.
///
/// The coordinates of the resulting [ui.TextBox] are relative to the
/// paragraph, not to the line.
ui.TextBox _intersect(int start, int end) {
// `_intersect` should only be called when there's an actual intersection.
assert(start > this.start || end < this.end);
final double before;
if (start <= this.start) {
before = 0.0;
} else {
_spanometer.currentSpan = span;
before = _spanometer.measureRange(this.start, start);
}
final double after;
if (end >= this.end - trailingNewlines) {
after = 0.0;
} else {
_spanometer.currentSpan = span;
after = _spanometer.measureRange(end, this.end - trailingNewlines);
}
final double left, right;
if (textDirection! == ui.TextDirection.ltr) {
// Example: let's say the text is "Loremipsum" and we want to get the box
// for "rem". In this case, `before` is the width of "Lo", and `after`
// is the width of "ipsum".
//
// Here's how the measurements/coordinates look like:
//
// before after
// |----| |----------|
// +---------------------+
// | L o r e m i p s u m |
// +---------------------+
// this.left ^ ^ this.right
left = this.left + before;
right = this.right - after;
} else {
// Example: let's say the text is "txet_werbeH" ("Hebrew_text" flowing from
// right to left). Say we want to get the box for "brew". The `before` is
// the width of "He", and `after` is the width of "_text".
//
// after before
// |----------| |----|
// +-----------------------+
// | t x e t _ w e r b e H |
// +-----------------------+
// this.left ^ ^ this.right
//
// Notice how `before` and `after` are reversed in the RTL example. That's
// because the text flows from right to left.
left = this.left + after;
right = this.right - before;
}
// The fragment's left and right edges are relative to the line. In order
// to make them relative to the paragraph, we need to add the left edge of
// the line.
return ui.TextBox.fromLTRBD(
line.left + left,
top,
line.left + right,
bottom,
textDirection!,
);
}
/// Returns the text position within this fragment's range that's closest to
/// the given [x] offset.
///
/// The [x] offset is expected to be relative to the left edge of the fragment.
ui.TextPosition getPositionForX(double x) {
x = _makeXDirectionAgnostic(x);
final int startIndex = start;
final int endIndex = end - trailingNewlines;
// Check some special cases to return the result quicker.
final int length = endIndex - startIndex;
if (length == 0) {
return ui.TextPosition(offset: startIndex);
}
if (length == 1) {
// Find out if `x` is closer to `startIndex` or `endIndex`.
final double distanceFromStart = x;
final double distanceFromEnd = widthIncludingTrailingSpaces - x;
return distanceFromStart < distanceFromEnd
? ui.TextPosition(offset: startIndex)
: ui.TextPosition(offset: endIndex, affinity: ui.TextAffinity.upstream,);
}
_spanometer.currentSpan = span;
// The resulting `cutoff` is the index of the character where the `x` offset
// falls. We should return the text position of either `cutoff` or
// `cutoff + 1` depending on which one `x` is closer to.
//
// offset x
// ↓
// "A B C D E F"
// ↑
// cutoff
final int cutoff = _spanometer.forceBreak(
startIndex,
endIndex,
availableWidth: x,
allowEmpty: true,
);
if (cutoff == endIndex) {
return ui.TextPosition(
offset: cutoff,
affinity: ui.TextAffinity.upstream,
);
}
final double lowWidth = _spanometer.measureRange(startIndex, cutoff);
final double highWidth = _spanometer.measureRange(startIndex, cutoff + 1);
// See if `x` is closer to `cutoff` or `cutoff + 1`.
if (x - lowWidth < highWidth - x) {
// The offset is closer to cutoff.
return ui.TextPosition(offset: cutoff);
} else {
// The offset is closer to cutoff + 1.
return ui.TextPosition(
offset: cutoff + 1,
affinity: ui.TextAffinity.upstream,
);
}
}
/// Transforms the [x] coordinate to be direction-agnostic.
///
/// The X (input) is relative to the [left] edge of the fragment, and this
/// method returns an X' (output) that's relative to beginning of the text.
///
/// Here's how it looks for a fragment with LTR content:
///
/// *------------------------line width------------------*
/// *-----X (input)
/// ┌───────────┬────────────────────────┬───────────────┐
/// │ │ ---text-direction----> │ │
/// └───────────┴────────────────────────┴───────────────┘
/// *-----X' (output)
/// *---left----*
/// *---------------right----------------*
///
///
/// And here's how it looks for a fragment with RTL content:
///
/// *------------------------line width------------------*
/// *-----X (input)
/// ┌───────────┬────────────────────────┬───────────────┐
/// │ │ <---text-direction---- │ │
/// └───────────┴────────────────────────┴───────────────┘
/// (output) X'-----------------*
/// *---left----*
/// *---------------right----------------*
///
double _makeXDirectionAgnostic(double x) {
if (textDirection == ui.TextDirection.rtl) {
return widthIncludingTrailingSpaces - x;
}
return x;
}
}
class EllipsisFragment extends LayoutFragment {
EllipsisFragment(
int index,
ParagraphSpan span,
) : super(
index,
index,
LineBreakType.endOfText,
null,
// The ellipsis is always at the end of the line, so it can't be
// sandwiched. This means it'll always follow the paragraph direction.
FragmentFlow.sandwich,
span,
trailingNewlines: 0,
trailingSpaces: 0,
);
@override
bool get isSpaceOnly => false;
@override
bool get isPlaceholder => false;
@override
String getText(CanvasParagraph paragraph) {
return paragraph.paragraphStyle.ellipsis!;
}
@override
List<LayoutFragment> split(int index) {
throw Exception('Cannot split an EllipsisFragment');
}
}