Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 189 additions & 7 deletions engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class TextLayout {
List<WebCluster> ellipsisClusters = <WebCluster>[];
int? _ellipsisBidiLevel;

// The hyphen for each soft hyphen break, keyed by the index of the cluster
// after the soft hyphen. Shaped on first use and kept across layouts.
final _softHyphens = <int, SoftHyphen?>{};

void performLayout(double width) {
// TODO(jlavrova): IMPLEMENT:
// I suggest with go with the general code flow for an empty text
Expand Down Expand Up @@ -157,6 +161,56 @@ class TextLayout {
return _ellipsisBidiLevel!;
}

/// Returns the hyphen drawn if a line breaks before the cluster at
/// [clusterIndex], or null if that break is not at a soft hyphen (U+00AD)
/// or no hyphen is drawn there.
///
/// Like SkParagraph, no hyphen is drawn for a soft hyphen at the end of the
/// text, or when [WebParagraphStyle.softHyphenString] is null or empty.
SoftHyphen? softHyphenBefore(int clusterIndex) {
// The last cluster is the artificial end of text.
if (clusterIndex <= 0 || clusterIndex >= allClusters.length - 1) {
return null;
}
// The browser gives the soft hyphen no cluster of its own, so check the
// code unit just before the break.
final int softHyphenOffset = allClusters[clusterIndex].start - 1;
if (paragraph.text.codeUnitAt(softHyphenOffset) != 0x00AD) {
return null;
}
Comment on lines +177 to +180

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The softHyphenOffset can be negative (e.g., if allClusters[clusterIndex].start is 0), which will cause paragraph.text.codeUnitAt(softHyphenOffset) to throw a RangeError and crash the layout process. Adding a bounds check for softHyphenOffset ensures defensive robustness.

Suggested change
final int softHyphenOffset = allClusters[clusterIndex].start - 1;
if (paragraph.text.codeUnitAt(softHyphenOffset) != 0x00AD) {
return null;
}
final int softHyphenOffset = allClusters[clusterIndex].start - 1;
if (softHyphenOffset < 0 || softHyphenOffset >= paragraph.text.length) {
return null;
}
if (paragraph.text.codeUnitAt(softHyphenOffset) != 0x00AD) {
return null;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this cannot happen here. allClusters is sorted by start and its clusters are not empty, and the check above returns null for clusterIndex <= 0, so the cluster starts at 1 or later. I am happy to add the check anyway if a reviewer prefers it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — 0 < clusterIndex < allClusters.length - 1 (before the artificial end-of-text cluster) and non-empty sorted clusters guarantee 1 <= allClusters[clusterIndex].start <= paragraph.text.length, so no extra bounds check is needed.

return _softHyphens.putIfAbsent(
clusterIndex,
() => _shapeSoftHyphen(clusterIndex, softHyphenOffset),
);
}

SoftHyphen? _shapeSoftHyphen(int clusterIndex, int softHyphenOffset) {
final String? hyphen = paragraph.paragraphStyle.softHyphenString;
if (hyphen == null || hyphen.isEmpty) {
return null;
}
// The hyphen takes the style of the soft hyphen, and the direction of the
// text it ends.
final ParagraphSpan span = paragraph.spans.firstWhere(
(ParagraphSpan span) => span.start <= softHyphenOffset && softHyphenOffset < span.end,
);
final int bidiLevel = bidiRuns
.firstWhere((BidiRun run) => run.clusterRange.overlapsWith(clusterIndex - 1, clusterIndex))
.bidiLevel;
final hyphenSpan = TextSpan(
start: 0,
end: hyphen.length,
style: span.style,
text: hyphen,
textDirection: bidiLevel.isEven ? ui.TextDirection.ltr : ui.TextDirection.rtl,
);
return SoftHyphen(
hyphenSpan,
bidiLevel,
ui.TextRange(start: softHyphenOffset, end: softHyphenOffset + 1),
);
}

void extractBidiRuns() {
assert(bidiRuns.isEmpty);

Expand Down Expand Up @@ -226,6 +280,7 @@ class TextLayout {
double top, {
required bool isSyntheticEmptyLine,
required bool isLastLine,
SoftHyphen? softHyphen,
}) {
assert(contentClusterRange.end == whitespaceClusterRange.start);
assert(whitespaceClusterRange.end == hardlineClusterRange.start);
Expand Down Expand Up @@ -349,11 +404,21 @@ class TextLayout {
// and shift all runs for this line so this first cluster starts from 0
// Break the line into the blocks that belong to the same bidi run (monodirectional text) and to the same style block (the same text metrics)
var trailingSpacesWidth = 0.0;
// In case we attach the ellipsis block at the left (RTL paragraph) we need to reserve its width
double blockShiftFromLineStart =
ellipsisBlock != null && paragraph.paragraphStyle.textDirection == ui.TextDirection.rtl
? ellipsisBlock.advance.width
: 0.0;
// Like SkParagraph, a hyphen at a soft hyphen break goes at the end of the line in the
// paragraph's direction, not next to the text it ends. A line with an ellipsis has none.
final SoftHyphen? lineSoftHyphen = ellipsisBlock == null ? softHyphen : null;
final isRtlParagraph = paragraph.paragraphStyle.textDirection == ui.TextDirection.rtl;
// In an RTL paragraph the hyphen block goes at the left edge of the line, so it can be
// built now.
final SoftHyphenBlock? rtlSoftHyphenBlock = isRtlParagraph && lineSoftHyphen != null
? SoftHyphenBlock(lineSoftHyphen, 0.0)
: null;
// In case we attach the ellipsis or hyphen block at the left (RTL paragraph) we need to
// reserve its width
var blockShiftFromLineStart = 0.0;
if (isRtlParagraph) {
blockShiftFromLineStart = (ellipsisBlock ?? rtlSoftHyphenBlock)?.advance.width ?? 0.0;
}
for (final bidiRun in lineVisualRuns) {
// We (almost always true) assume that trailing whitespaces do not affect the line height.
// Let's keep it as is.
Expand Down Expand Up @@ -503,6 +568,22 @@ class TextLayout {
line.updateBoundingBox(ellipsisBlock);
}

// Add the hyphen block if any
if (lineSoftHyphen != null) {
final SoftHyphenBlock softHyphenBlock;
if (rtlSoftHyphenBlock != null) {
// The width is already reserved at the beginning of the line
softHyphenBlock = rtlSoftHyphenBlock;
line.visualBlocks.insert(0, softHyphenBlock);
} else {
softHyphenBlock = SoftHyphenBlock(lineSoftHyphen, blockShiftFromLineStart);
line.visualBlocks.add(softHyphenBlock);
blockShiftFromLineStart += softHyphenBlock.advance.width;
}
line.updateBoundingBox(softHyphenBlock);
line.softHyphenWidth = softHyphenBlock.advance.width;
}

// Now when we calculated all line metrics we have to correct placeholders that depend on it
for (final LineBlock block in line.visualBlocks) {
if (block is! PlaceholderBlock) {
Expand Down Expand Up @@ -664,7 +745,8 @@ class TextLayout {
continue;
}
ui.Rect firstRect = block.advance;
if (block is! PlaceholderBlock) {
// A hyphen block covers the whole drawn hyphen for its one soft hyphen.
if (block is! PlaceholderBlock && block is! SoftHyphenBlock) {
// We need to calculate the intersect rectangle
firstRect = (block.span as TextSpan).getTextRangeSelectionInBlock(block, intersect);
}
Expand Down Expand Up @@ -862,6 +944,16 @@ class TextLayout {
final double left = block.advance.left + lineShift;
final double right = block.advance.right + lineShift;

if (block is SoftHyphenBlock) {
// Like SkParagraph, the hyphen maps to the position after the soft hyphen.
// Because that offset is also the start of the next line, use upstream affinity so
// line-boundary and closest-glyph queries keep the position on the hyphenated line.
if (right < offset.dx) {
continue;
}
return ui.TextPosition(offset: block.textRange.end, affinity: ui.TextAffinity.upstream);
}

if (right < offset.dx) {
// We are not there yet; we need a block containing the offset (or the closest to it)
continue;
Expand Down Expand Up @@ -914,6 +1006,14 @@ class TextLayout {
}
// We found the line but not the block because the offset is to the right of all blocks in this line.
final LineBlock? lastVisualBlockInLine = line.visualBlocks.lastOrNull;
if (lastVisualBlockInLine is SoftHyphenBlock) {
// Offsets past a trailing soft hyphen map to the break boundary at the end of this line,
// so use upstream affinity to keep the position on the hyphenated line.
return ui.TextPosition(
offset: lastVisualBlockInLine.textRange.end,
affinity: ui.TextAffinity.upstream,
);
}
return lastVisualBlockInLine == null
? ui.TextPosition(offset: line.allLineTextRange.start, affinity: ui.TextAffinity.upstream)
: lastVisualBlockInLine.isLtr
Expand All @@ -940,6 +1040,20 @@ class TextLayout {
// We cannot assume clusters go sequentially because of bidi reshuffling
// but we don't care about the order because we only look for a cluster that contains the offset
for (final LineBlock visualBlock in line.visualBlocks) {
if (visualBlock is SoftHyphenBlock) {
// A soft hyphen at a line break has no cluster; its glyph is the drawn hyphen.
if (visualBlock.textRange.start == codeUnitOffset) {
return ui.GlyphInfo(
visualBlock.advance.translate(
line.advance.left + line.formattingShift,
line.advance.top + line.fontBoundingBoxAscent,
),
visualBlock.textRange,
visualBlock.isLtr ? ui.TextDirection.ltr : ui.TextDirection.rtl,
);
}
continue;
}
for (
int start = visualBlock.clusterRange.start;
start < visualBlock.clusterRange.end;
Expand Down Expand Up @@ -1495,6 +1609,69 @@ class EllipsisBlock extends TextBlock {
);
}

/// The hyphen drawn at a line break at a soft hyphen (U+00AD).
class SoftHyphen {
SoftHyphen(this.span, this.bidiLevel, this.textRange);

/// The hyphen string, shaped in the soft hyphen's style.
final TextSpan span;

/// The bidi level of the text the hyphen ends.
final int bidiLevel;

/// The soft hyphen's range in the paragraph text.
final ui.TextRange textRange;

late final double width = span.advanceWidth()!;
}

/// A block for the hyphen drawn at the end of a line that breaks at a soft
/// hyphen (U+00AD).
///
/// The hyphen is shaped separately from the paragraph text, so the block owns
/// its clusters and covers no paragraph clusters. Its [textRange] is the soft
/// hyphen's, so that queries over the soft hyphen find the drawn hyphen, as in
/// SkParagraph.
class SoftHyphenBlock extends TextBlock {
factory SoftHyphenBlock(SoftHyphen softHyphen, double shiftFromLineStart) {
final List<TextCluster> clusters = softHyphen.span.extractClusters();
final TextCluster firstVisualCluster = softHyphen.bidiLevel.isEven
? clusters.first
: clusters.last;
return SoftHyphenBlock._(
softHyphen,
clusters,
shiftFromLineStart,
firstVisualCluster.advance.left,
);
}

SoftHyphenBlock._(
SoftHyphen softHyphen,
this.clusters,
double shiftFromLineStart,
double shiftFromSpanStart,
) : super(
softHyphen.span,
softHyphen.bidiLevel,
ClusterRange.collapsed(0),
softHyphen.textRange,
// Relative to the hyphen's own span: the whole hyphen string.
ui.TextRange(start: 0, end: softHyphen.span.text.length),
shiftFromLineStart,
shiftFromSpanStart,
);

final List<TextCluster> clusters;

@override
Iterable<(WebCluster, bool)> getTextClustersInVisualOrder(TextLayout layout) sync* {
for (final TextCluster cluster in isLtr ? clusters : clusters.reversed) {
yield (cluster, isLtr);
}
}
}

class TextLine {
TextLine(
this.textClusterRange,
Expand All @@ -1521,7 +1698,8 @@ class TextLine {
// It was not implemented in SkParagraph either; kept it as is
unscaledAscent: fontBoundingBoxAscent,
height: advance.height,
width: advance.width,
// Like SkParagraph, the width leaves out a hyphen at a soft hyphen break.
width: advance.width - softHyphenWidth,
// TODO(jlavrova): This should be set to `formattingShift`, right?
// `advance.left` is always zero.
left: advance.left,
Expand Down Expand Up @@ -1594,6 +1772,10 @@ class TextLine {
double formattingShift = 0.0; // For centered or right aligned text
late final double trailingSpacesWidth;

/// The width of the hyphen drawn at the end of the line, if it breaks at a
/// soft hyphen. It is included in [advance].
double softHyphenWidth = 0.0;

double get fullWidth => advance.width + formattingShift + trailingSpacesWidth;

List<LineBlock> visualBlocks = <LineBlock>[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,14 @@ class WebParagraphStyle implements ui.ParagraphStyle {

final ui.TextHeightBehavior? textHeightBehavior;

// TODO(dbebawy): honor hyphens in the pure-Dart line-breaker/painter (no
// soft-hyphen glyph emitted at a break yet). Accepted and stored for now.
// https://github.com/flutter/flutter/issues/193506
final ui.Hyphens? hyphens;

/// The string drawn at the end of a line that breaks at a soft hyphen
/// (U+00AD), or null if none is drawn.
// TODO(dbebawy): Use hyphenCharacter once dart:ui has it.
// https://github.com/flutter/flutter/issues/189617
String? get softHyphenString => hyphens == ui.Hyphens.hidden ? null : '-';

WebStrutStyle? get strutStyle => _strutStyle;
final WebStrutStyle? _strutStyle;

Expand Down
Loading
Loading