add debugPaintTextLayoutBoxes for debugging text layout (#168876)

Text vertical alignment is a common complaint and one of the most common
causes is different type faces being used in the same paragraph (for
instance, when there's English / Arabic in the same paragraph so the
default font doesn't contain the required glyphs for some characters).

Hopefully this flag is going to make debugging alignment issues a bit
easier, and in some github issues attaching screenshots with the flag on
will be helpful for diagnosing the issue.

### What the layout boxes look like 
```dart
final testText =
    'Quick brown fox, نص, trailing spaces${" " * 100}'
    'new line at the end.\n\n\n\n';

Column([
              Text(testText, style: TextStyle(height: kTextHeightNone)),
              Text(testText, style: TextStyle(height: 1.0)),
              Text(
                testText,
                style: TextStyle(height: kTextHeightNone),
                strutStyle: StrutStyle(fontSize: 30),
              ),
])
```
<img width="410" alt="image"
src="https://github.com/user-attachments/assets/788b8089-373f-4af0-9144-306b3e755614"
/>

## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
LongCatIsLooong 2025-07-17 12:49:30 -07:00 committed by GitHub
parent ae0700d94a
commit 844edbcd91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 87 additions and 5 deletions

View File

@ -1291,6 +1291,22 @@ class TextPainter {
_layoutCache = newLayoutCache;
}
/// Causes the paragraph to paint the layout boxes of the text.
///
/// {@template flutter.painting.textPainter.debugPaintTextLayoutBoxes}
/// Each painted box illustrates how the encompassed text contributes to the
/// overall text layout. For instance, for paragraphs whose [StrutStyle] is
/// disabled, the line height of a line is the smallest vertical extent that
/// covers all text boxes on that line.
///
/// Typically, only characters with a non-zero horizontal advance produce
/// these boxes. No boxes will be painted for lines that only consist of a new
/// line character.
/// {@endtemplate}
///
/// The [paint] method reads this flag only in debug mode.
bool debugPaintTextLayoutBoxes = false;
/// Paints the text onto the given canvas at the given offset.
///
/// Valid only after [layout] has been called.
@ -1335,9 +1351,31 @@ class TextPainter {
assert(debugSize == size);
}
assert(!_rebuildParagraphForPaint);
assert(
!debugPaintTextLayoutBoxes || _debugPaintCharacterLayoutBoxes(canvas, layoutCache, offset),
);
canvas.drawParagraph(layoutCache.paragraph, offset + layoutCache.paintOffset);
}
bool _debugPaintCharacterLayoutBoxes(
Canvas canvas,
_TextPainterLayoutCacheWithOffset layout,
Offset offset,
) {
final Paint paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.0
..color = const Color(0xFF00FFFF);
final List<TextBox> textBoxes = getBoxesForSelection(
TextSelection(baseOffset: 0, extentOffset: plainText.length),
);
for (final TextBox textBox in textBoxes) {
canvas.drawRect(textBox.toRect().shift(offset), paint);
}
return true;
}
// Returns true if value falls in the valid range of the UTF16 encoding.
static bool _isUTF16(int value) {
return value >= 0x0 && value <= 0xFFFFF;
@ -1441,17 +1479,22 @@ class TextPainter {
return Offset(adjustedDx, rawOffset.dy + layoutCache.paintOffset.dy);
}
// The condition is derived from
// https://github.com/google/skia/blob/0086a17e0d4cc676cf88cae671ba5ee967eb7241/modules/skparagraph/src/TextLine.cpp#L1244-L1246
// which is set here:
// https://github.com/flutter/engine/blob/a821b8790c9fd0e095013cd5bd1f20273bc1ee47/third_party/txt/src/skia/paragraph_builder_skia.cc#L134
bool get _strutDisabled => switch (strutStyle) {
null || StrutStyle.disabled => true,
StrutStyle(:final double? fontSize) => fontSize == 0.0,
};
/// {@template flutter.painting.textPainter.getFullHeightForCaret}
/// Returns the strut bounded height of the glyph at the given `position`.
/// {@endtemplate}
///
/// Valid only after [layout] has been called.
double getFullHeightForCaret(TextPosition position, Rect caretPrototype) {
// The if condition is derived from
// https://github.com/google/skia/blob/0086a17e0d4cc676cf88cae671ba5ee967eb7241/modules/skparagraph/src/TextLine.cpp#L1244-L1246
// which is set here:
// https://github.com/flutter/engine/blob/a821b8790c9fd0e095013cd5bd1f20273bc1ee47/third_party/txt/src/skia/paragraph_builder_skia.cc#L134
if (strutStyle == null || strutStyle == StrutStyle.disabled || strutStyle?.fontSize == 0.0) {
if (_strutDisabled) {
final double? heightFromCaretMetrics = _computeCaretMetrics(position)?.height;
if (heightFromCaretMetrics != null) {
return heightFromCaretMetrics;

View File

@ -38,6 +38,15 @@ bool debugPaintSizeEnabled = false;
/// Causes each RenderBox to paint a line at each of its baselines.
bool debugPaintBaselinesEnabled = false;
/// Causes each RenderParagraph to paint the layout boxes of its text.
///
/// {@macro flutter.painting.textPainter.debugPaintTextLayoutBoxes}
///
/// See also:
///
/// * [debugPaintBaselinesEnabled] which helps debug text alignment.
bool debugPaintTextLayoutBoxes = false;
/// Causes each Layer to paint a box around its bounds.
bool debugPaintLayerBordersEnabled = false;
@ -321,6 +330,7 @@ bool debugAssertAllRenderVarsUnset(String reason, {bool debugCheckIntrinsicSizes
if (debugPaintSizeEnabled ||
debugPaintBaselinesEnabled ||
debugPaintLayerBordersEnabled ||
debugPaintTextLayoutBoxes ||
debugPaintPointersEnabled ||
debugRepaintRainbowEnabled ||
debugRepaintTextRainbowEnabled ||

View File

@ -1007,6 +1007,11 @@ class RenderParagraph extends RenderBox
}
}
assert(() {
_textPainter.debugPaintTextLayoutBoxes = debugPaintTextLayoutBoxes;
return true;
}());
_textPainter.paint(context.canvas, offset);
paintInlineChildren(context, offset);

View File

@ -1957,6 +1957,30 @@ void main() {
expect(painter.height, 10);
});
test('debugPaintTextLayoutBoxes', () {
const TextSpan span = TextSpan(
text: 'M',
// ascent = 96, descent = 32
style: TextStyle(fontSize: 128),
children: <InlineSpan>[TextSpan(text: 'M', style: TextStyle(fontSize: 64))],
);
final TextPainter painter = TextPainter()
..textDirection = TextDirection.ltr
..text = span
..layout();
expect(
(Canvas canvas) {
painter.debugPaintTextLayoutBoxes = true;
painter.paint(canvas, Offset.zero);
painter.debugPaintTextLayoutBoxes = false;
},
paints
..rect(rect: Offset.zero & const Size.square(128))
..rect(rect: const Offset(128, 96 - 48) & const Size.square(64)),
);
});
test('TextPainter dispatches memory events', () async {
await expectLater(
await memoryEvents(() => TextPainter().dispose(), TextPainter),