Expose 3 new methods with text metrics in RenderParagraph#65150
Expose 3 new methods with text metrics in RenderParagraph#65150fluttergithubbot merged 4 commits intoflutter:masterfrom
Conversation
| layout(paragraph); | ||
|
|
||
| final TextRange range5 = paragraph.getLineBoundary(const TextPosition(offset: 5)); | ||
| expect(range5.textInside(_kText), equals('I polished up that handle so carefullee')); |
There was a problem hiding this comment.
The doc on TextPainter says "The newline, if any, is included in the range". However this doesn't seem to be the case... or I missunderstand this statement.
I couldn't find any tests for TextPainter.getLineBoundary to see if this is expected.
There was a problem hiding this comment.
Oh, interesting. So the web tests failed here with:
07:30 +485 ~12 -1: /b/s/w/ir/k/flutter/packages/flutter/test/rendering/paragraph_test.dart: getLineBoundary control test [E]
Expected: 'I polished up that handle so carefullee'
Actual: 'I polished up that handle so carefullee\n'
''
Which: is different. Both strings start the same, but the actual value also has the following trailing characters: \n
Which sounds like implementation for the web platform is more aligned with the doc... ?
Running this test locally on macOS works fine.
I'm probably going to need help determining which behavior is the correct one and whether I should be looking at the TextPainter implementation to fix this inconsistency.
There was a problem hiding this comment.
I would agree with you, something seems not right there. Let's see if @GaryQian knows what the behavior should be.
There are some tests in the web part of the engine for this, and they include the newline: https://github.com/flutter/engine/blob/09a5bf7acf44dbb22d68830f5a51ebe822d5e659/lib/web_ui/test/paragraph_test.dart#L926
There was a problem hiding this comment.
This may indeed be a bug, I think the newline makes sense here, especially since the docs indicate that it was meant to be included. It isn't your responsibility to fix that though, so you may want to just mark it with a comment and a TODO: blahblah to indicate that the result here may be due to a bug. Then, you can duplicate the test and make one of them skip web while having the other skip android+ios. We should file a bug on this.
|
Could you add some more details to the pull request description about the use case(s) for this? |
@dnfield sure, updated the description with my use case. |
|
These additions seem fine in general. @GaryQian is the right person to make calls on the details of the API. We definitely want Web and VM to have identical behavior. It might involve changing the documentation or the implementations or both. For the tests I recommend trying to be more specific in the conditions. The more precise the tests are the less likely it is that one day we will accidentally regress things. |
justinmc
left a comment
There was a problem hiding this comment.
Thanks for opening this! I'm excited about Flutter being able to support rich text applications like your project. I'm on board with exposing these methods, but let's wait for Gary to chime in too.
| /// This does not required the layout to be updated. | ||
| double get preferredLineHeight => _textPainter.preferredLineHeight; | ||
|
|
||
|
|
| } | ||
|
|
||
| /// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight]. | ||
| /// This does not required the layout to be updated. |
There was a problem hiding this comment.
Or maybe this? "Accessing this value does not cause the layout to be updated."
There was a problem hiding this comment.
Removed this method per @GaryQian 's recommendation.
|
|
||
| /// Returns the tight bounded height of the glyph at the given [position]. | ||
| /// | ||
| /// Valid only after [layout]. |
There was a problem hiding this comment.
Any of these docs comments that were copied from text_painter.dart should use a macro instead.
Here's an example of how to do that:
flutter/packages/flutter/lib/src/painting/text_painter.dart
Lines 335 to 337 in 0bb1e57
There was a problem hiding this comment.
Thanks for the examples!
| layout(paragraph); | ||
|
|
||
| final TextRange range5 = paragraph.getLineBoundary(const TextPosition(offset: 5)); | ||
| expect(range5.textInside(_kText), equals('I polished up that handle so carefullee')); |
There was a problem hiding this comment.
I would agree with you, something seems not right there. Let's see if @GaryQian knows what the behavior should be.
There are some tests in the web part of the engine for this, and they include the newline: https://github.com/flutter/engine/blob/09a5bf7acf44dbb22d68830f5a51ebe822d5e659/lib/web_ui/test/paragraph_test.dart#L926
|
|
||
| /// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight]. | ||
| /// This does not required the layout to be updated. | ||
| double get preferredLineHeight => _textPainter.preferredLineHeight; |
There was a problem hiding this comment.
If you have a workaround for preferredLineHeight, I would prefer that we hold off on exposing this as official API. The current implementation of this value is sketchy at best and is something I want to eventually rework.
Some problems with this value is that it assumes an alphabetic space as the character to get metrics from. This causes some issues with ideographic text such as Chinese or even Arabic in some fonts. The current implementation also does not account for the differences in first line vs second+ line height, so could produce values that are not useful beyond the first line.
For now, I suggest continuing to use your workaround instead of adding this API that may have to be broken in the future.
There was a problem hiding this comment.
Makes sense, thanks! I can keep my workaround for now and remove this method from here.
| return _textPainter.getOffsetForCaret(position, caretPrototype); | ||
| } | ||
|
|
||
| /// Returns the tight bounded height of the glyph at the given [position]. |
There was a problem hiding this comment.
tight -> strut
Can you change this in the original docs in TextPainter as well? This doc is no longer accurate.
There was a problem hiding this comment.
Going off of the macro comment by Justin, change this in the original doc, and then macro it here.
There was a problem hiding this comment.
Updated and added a macro in TextPainter. I haven't included the last line referencing [layout] of TextPainter as it seemed specific to the painter class itself (even though RenderParagraph has a similar requirement).
Let me know if you'd like that line to be included in the template, I'll update.
Also changed [position] to use backticks, as it seems more inline with the style guide around method parameters.
| /// The newline, if any, is included in the range. | ||
| /// | ||
| /// Valid only after [layout]. | ||
| TextRange getLineBoundary(TextPosition position) { |
There was a problem hiding this comment.
The primary reason this is not exposed is API bloat, since once we add it, it cannot be removed. We should try our best to explore reasonable alternatives before resorting to adding additional API.
In this case, getLineBoundary exposes a few of the text position APIs based on metrics, which we want to eventually move away from to contain more bitdepth.
I think you may be able to workaround this by calling getBoxesForSelection, once with a collapsed selection to get the rect that bounds the current position, and again with a wide selection that encompasses a few lines. Then, you can take the right most and left most boundaries of the boxes that share the same y offset as the first call. Then pass those boundaries to getPositionForOffset.
That should get you something that may work for your purposes. If the resulting code is really complicated, confusing, or unreliable, we can proceed with exposing this.
There was a problem hiding this comment.
Let me know if you are able to get a workaround working!
There was a problem hiding this comment.
Yep it does seem to work pretty well. Thanks for the suggestion! Since each RenderParagraph in Zefyr represents a single paragraph of text (meaning text between two newlines) here is what I ended up with:
@override
TextRange getLineBoundary(TextPosition position) {
// getOffsetForCaret returns top-left corner of the caret. To find all
// selection boxes on the same line we shift caret offset by 0.5 of
// preferredLineHeight so that it's in the middle of the line and filter out
// boxes which do not include this offset on the Y axis.
final caret = getOffsetForCaret(position);
final lineDy = caret.translate(0.0, 0.5 * preferredLineHeight(position)).dy;
final boxes = getBoxesForSelection(
TextSelection(baseOffset: 0, extentOffset: node.length - 1));
final lineBoxes = boxes
.where((element) => element.top < lineDy && element.bottom > lineDy)
.toList(growable: false);
final start = getPositionForOffset(Offset(lineBoxes.first.left, lineDy));
final end = getPositionForOffset(Offset(lineBoxes.last.right, lineDy));
return TextRange(start: start.offset, end: end.offset);
}I'm checking at the vertical middle of the line because I've noticed that emojis, for instance, have larger selection boxes (and they also mess with the line height they are at):

Overall it's actually pretty concise and I'm happy with this approach. So I removed getLineBoundary from this PR.
It now only exposes getFullHeightForCaret.
| /// Returns the tight bounded height of the glyph at the given [position]. | ||
| /// | ||
| /// Valid only after [layout]. | ||
| double? getFullHeightForCaret(TextPosition position, Rect caretPrototype) { |
There was a problem hiding this comment.
This should be ok to expose, as the CaretMetrics construct is native to TextPainter.
The name "fullHeight" seems a bit ambiguous in hindsight especially since there is no API for not-full height here. We may want to consider renaming it to something like "rawHeight" here, indicating it is unprocessed and a direct result of the caret computation. The alternative is to just keep the naming as "full" for consistency.
I am leaning towards keeping it as "full" as diverging API is not good. @justinmc any opinions?
There was a problem hiding this comment.
Is there anything else that refers to "full height" in the codebase? Not that I could think of or find with a quick search.
If it's really just this method, maybe we should nip it in the bud and switch to "rawHeight" while we have the chance? If I'm wrong then it definitely makes sense to be consistent and use "fullHeight".
GaryQian
left a comment
There was a problem hiding this comment.
Left some comments regarding the background/logic around whether to expose API or not.
| const Rect caret = Rect.fromLTWH(0.0, 0.0, 2.0, 20.0); | ||
|
|
||
| final double height5 = paragraph.getFullHeightForCaret(const TextPosition(offset: 5), caret); | ||
| expect(height5, equals(10.0)); |
There was a problem hiding this comment.
Per @Hixie suggestion I updated this test to be more strict in comparing expected value.
|
@justinmc @GaryQian thanks for all the feedback! I've updated the PR and removed This should be ready for another look. |
| double? getFullHeightForCaret(TextPosition position, Rect caretPrototype) { | ||
| assert(!debugNeedsLayout); | ||
| _layoutTextWithConstraints(constraints); | ||
| return _textPainter.getFullHeightForCaret(position, caretPrototype); |
There was a problem hiding this comment.
Oh yeah, one more thing before submitting, caretPrototype here does not do anything useful as in getFullHeightForCaret, it only contributes width, which is then ignored. You should just remove the parameter and pass a zero rect instead in the method body.
Description
Exposes
preferredLineHeight,getFullHeightForCaretandgetLineBoundaryonRenderParagraph.Use case
I've created a rich text editor for Flutter and currently work on cleaning up and refactoring its rendering layer.
In the editor I'm taking advantage of the built-in
RenderParagraphto represent a single paragraph in a document.Since I'm using it in an editable context I need it to provide a few more metrics related to handling of editing cursor:
preferredLineHeightgetFullHeightForCaretgetLineBoundaryThese methods would be proxying to the underlying
TextPainterinstance. Right now I have implemented a workaround forpreferredLineHeightbut it doesn't seem like I can create one forgetFullHeightForCaretorgetLineBoundary.I noticed that
RenderParagraphalready exposes some editing-specific methods (getOffsetForCaretandgetBoxesForSelection) so it doesn't seem like it'd go against certain design goals for this class.Related Issues
Tests
Added the following tests in
packages/flutter/test/rendering/paragraph_test.dart:preferredLineHeight control testgetFullHeightForCaret control testgetLineBoundary control testChecklist
///).flutter analyze --flutter-repo) does not report any problems on my PR.Breaking Change
Did any tests fail when you ran them? Please read Handling breaking changes.