Skip to content

Expose 3 new methods with text metrics in RenderParagraph#65150

Merged
fluttergithubbot merged 4 commits intoflutter:masterfrom
pulyaevskiy:render-paragraph-caret-api
Sep 9, 2020
Merged

Expose 3 new methods with text metrics in RenderParagraph#65150
fluttergithubbot merged 4 commits intoflutter:masterfrom
pulyaevskiy:render-paragraph-caret-api

Conversation

@pulyaevskiy
Copy link
Contributor

@pulyaevskiy pulyaevskiy commented Sep 3, 2020

Description

Exposes preferredLineHeight, getFullHeightForCaret and getLineBoundary on RenderParagraph.

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 RenderParagraph to 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:

  • preferredLineHeight
  • getFullHeightForCaret
  • getLineBoundary

These methods would be proxying to the underlying TextPainter instance. Right now I have implemented a workaround for preferredLineHeight but it doesn't seem like I can create one for getFullHeightForCaret or getLineBoundary.

I noticed that RenderParagraph already exposes some editing-specific methods (getOffsetForCaret and getBoxesForSelection) 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 test
  • getFullHeightForCaret control test
  • getLineBoundary control test

Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I signed the CLA.
  • I read and followed the Flutter Style Guide, including Features we expect every widget to implement.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I updated/added relevant documentation (doc comments with ///).
  • All existing and new tests are passing.
  • The analyzer (flutter analyze --flutter-repo) does not report any problems on my PR.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

Did any tests fail when you ran them? Please read Handling breaking changes.

@flutter-dashboard flutter-dashboard bot added the framework flutter/packages/flutter repository. See also f: labels. label Sep 3, 2020
layout(paragraph);

final TextRange range5 = paragraph.getLineBoundary(const TextPosition(offset: 5));
expect(range5.textInside(_kText), equals('I polished up that handle so carefullee'));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Contributor

Choose a reason for hiding this comment

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

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.

@dnfield
Copy link
Contributor

dnfield commented Sep 3, 2020

Could you add some more details to the pull request description about the use case(s) for this?

@pulyaevskiy
Copy link
Contributor Author

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.

@dnfield dnfield added the a: typography Text rendering, possibly libtxt label Sep 3, 2020
@Hixie
Copy link
Contributor

Hixie commented Sep 3, 2020

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.

Copy link
Contributor

@justinmc justinmc left a comment

Choose a reason for hiding this comment

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

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;


Copy link
Contributor

Choose a reason for hiding this comment

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

Extra newline here.

}

/// An estimate of the height of a line in the text. See [TextPainter.preferredLineHeight].
/// This does not required the layout to be updated.
Copy link
Contributor

Choose a reason for hiding this comment

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

required => require

Copy link
Contributor

Choose a reason for hiding this comment

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

Or maybe this? "Accessing this value does not cause the layout to be updated."

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed this method per @GaryQian 's recommendation.


/// Returns the tight bounded height of the glyph at the given [position].
///
/// Valid only after [layout].
Copy link
Contributor

Choose a reason for hiding this comment

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

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:

/// {@template flutter.painting.textPainter.textWidthBasis}
/// Defines how to measure the width of the rendered text.
/// {@endtemplate}

/// {@macro flutter.painting.textPainter.textWidthBasis}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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'));
Copy link
Contributor

Choose a reason for hiding this comment

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

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;
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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].
Copy link
Contributor

Choose a reason for hiding this comment

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

tight -> strut

Can you change this in the original docs in TextPainter as well? This doc is no longer accurate.

Copy link
Contributor

Choose a reason for hiding this comment

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

Going off of the macro comment by Justin, change this in the original doc, and then macro it here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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) {
Copy link
Contributor

@GaryQian GaryQian Sep 8, 2020

Choose a reason for hiding this comment

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

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.

Copy link
Contributor

Choose a reason for hiding this comment

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

Let me know if you are able to get a workaround working!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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:

https://github.com/memspace/zefyr/blob/78a5ac11542d39a45cc11cb904416dec0c443da7/packages/zefyr/lib/src/rendering/editable_text_line.dart#L294-L309

  @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):
image

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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Contributor

Choose a reason for hiding this comment

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

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".

Copy link
Contributor

@GaryQian GaryQian left a comment

Choose a reason for hiding this comment

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

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));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Per @Hixie suggestion I updated this test to be more strict in comparing expected value.

@pulyaevskiy
Copy link
Contributor Author

@justinmc @GaryQian thanks for all the feedback! I've updated the PR and removed preferredLineHeight and getLineBoundary, keeping only getFullHeightForCaret with a test. Plus suggested doc changes.

This should be ready for another look.
I'll file a bug for the TextPainter.getLineBoundary inconsistency some time tomorrow.

Copy link
Contributor

@GaryQian GaryQian left a comment

Choose a reason for hiding this comment

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

LGTM! Thanks for making the changes, and I appreciate your understanding for our efforts in keeping the API clean.

double? getFullHeightForCaret(TextPosition position, Rect caretPrototype) {
assert(!debugNeedsLayout);
_layoutTextWithConstraints(constraints);
return _textPainter.getFullHeightForCaret(position, caretPrototype);
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated.

@fluttergithubbot fluttergithubbot merged commit bb59993 into flutter:master Sep 9, 2020
@pulyaevskiy pulyaevskiy deleted the render-paragraph-caret-api branch September 10, 2020 17:00
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Sep 10, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

a: typography Text rendering, possibly libtxt framework flutter/packages/flutter repository. See also f: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose preferredLineHeigh, getFullHeightForCaret and getLineBoundary on RenderParagraph

7 participants