Skip to content

Add Design Doc for Composable Text Plugins in Flutter - #35

Open
hannah-hyj wants to merge 7 commits into
flutter:mainfrom
hannah-hyj:main
Open

hannah-hyj wants to merge 7 commits into
flutter:mainfrom
hannah-hyj:main

Conversation

@hannah-hyj

@hannah-hyj hannah-hyj commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

demos:
Stage 1 (painter only): https://text-plugin-mvp-demo.web.app/
Stage 2 (annotations): https://text-plugin-annotations-demo.web.app/
Stage 3 (widgets): https://text-plugin-widget-demo.web.app/

Prototype branches link:
Stage 1 (painter only): https://github.com/hannah-hyj/flutter/tree/text-plugin-mvp
Stage 2 (annotations): https://github.com/hannah-hyj/flutter/tree/text-plugin-annotations
Stage 3 (widgets): https://github.com/hannah-hyj/flutter/tree/text-plugin-with-widget

If you want to access demo app code , it's in {branch}/examples/text_plugins

issue: flutter/flutter#65504

#25 was closed in favor of this one

Summary

Proposes a composable text plugin architecture (TextPluginScope, TextPlugin, and TextDelegate) in package:flutter/rendering.dart and package:flutter/widgets.dart.

This enables cross-cutting text inspection, background/foreground decoration painting, pointer gesture handling, and scroll-to-range across standard Text, RichText, and EditableText (TextField) subtrees (RenderParagraph and RenderEditable) without requiring custom text widgets or TextSpan wrappers.

Key Use Cases

  • In-page find & highlight (Ctrl+F) across lazy scrollables (ListView.builder) and text fields
  • Non-intrusive inline annotations, spellcheck/grammar squiggles, and interactive token tooltips
  • Accessibility and localization auditing overlays (e.g., text truncation, contrast, and scaling diagnostics)

Pre-launch Checklist

  • I read RFC 000.0001: Taxonomy and followed the file and path naming and metadata standards.
  • I read RFC 000.0002: Process and confirmed this proposal meets the threshold for a full RFC.
  • I read and agree to the Code of Conduct.
  • I signed the CLA.
  • I have linked an issue from flutter/flutter with the label design doc.
  • All existing and new tests are passing.
  • I have enabled "Allow edits from maintainers" on this PR so the bot can automatically assign an RFC number (or I will run dart run bin/assign_rfc_number.dart locally when instructed).

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

@hannah-hyj hannah-hyj changed the title RFC 170.0000: Composable Text Plugins in Flutter Add Design Composable Text Plugins in Flutter Oct 15, 2026
@hannah-hyj hannah-hyj changed the title Add Design Composable Text Plugins in Flutter Add Design Doc for Composable Text Plugins in Flutter Oct 15, 2026
@fanchou

This comment was marked as resolved.

@hannah-hyj

This comment was marked as resolved.

Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved

@justinmc justinmc left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Quick drive by review until I can take a deeper look:

The demo is awesome. Can you share the source code? I think the repo (https://github.com/hannah-hyj/flutter_text_plugin_demo) contains only the compiled assets.

One thing we should be wary of in this design is complexity that increases with the amount of text on the page. Flutter is currently already pretty bad at handling large amounts of text, but we should avoid making that worse if we can.

Comment thread rfc/170.0000-composable-text-plugins.md Resolved

When a user starts in the middle of a `ListView.builder` (such as `Item 10`) and scrolls upward, `RenderSliverList` mounts `Item 9`, then `Item 8`, then `Item 7`. As a result, `didAddText` fires in the order `[Item 10, Item 9, Item 8, Item 7]`. If `SearchInPagePlugin` iterates over `_delegates` in registration order, pressing "Next Match" jumps backward up the document.

Comparing global screen coordinates (`localToGlobal(Offset.zero).dy`) also fails for general layouts. Two side-by-side columns or wrapped flex items can have overlapping vertical coordinates even though they have a well-defined reading order in the widget tree.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SelectableRegion has this same problem, we've worked around this in the text case by ordering by topLeft, but this likely is not robust enough for all text use-cases. Would be interested in seeing if this same LCA ordering can be used for SelectableRegion and if there's anything that can be shared here.

Comment thread rfc/170.0000-composable-text-plugins.md Outdated

Domain-specific or third-party-dependent features belong in standalone community packages:

1. `SpellCheckPlugin` (`package:flutter_spellcheck`) renders IME spellcheck squiggly underlines and grammar suggestions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'd argue this may be a good candidate for a first-party plugin since spell check is a default feature we currently provide. That decoration is baked into the InlineSpan itself but it does surface the question about what default capabilities should use the plugin system or just directly modify the InlineSpan for decorations.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do you think this should be in flutter/flutter or flutter/package? that is - is this a OS behavior or design library behavior

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I agree it is a good candidate. On flutter/flutter versus flutter/packages I do not have an answer yet, and I listed it as open.

Comment thread rfc/170.0000-composable-text-plugins.md Resolved

##### 2. Per-widget `Text(selectionColor: ...)` and inline `WidgetSpan`s

A single `_SelectionHighlightTextPlugin` installed at the root `SelectableRegion` only knows the default selection color from `DefaultSelectionStyle`. If a descendant `Text('Warning', selectionColor: Colors.red)` overrides its selection color, or if a `Text.rich` contains inline `WidgetSpan`s that split the paragraph into multiple selectable ranges, the plugin cannot render accurately unless `TextDelegate` exposes per-paragraph `selectionColor` and `selections` (where `getBoxesForSelection` defaults to `includePlaceholders: false` so inline widgets are not highlighted).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good callout, I wonder if we can have a OverrideSelectionStyleTextPlugin or similar that a user can wrap around a subtree to handle this use-case. Probably non-blocking for initial implementation but we should consider it if we do plan to migrate selectableregion to use the plugin. Personally I lean towards that migration since it further decouples selection from text.

Comment thread rfc/170.0000-composable-text-plugins.md Resolved

@justinmc justinmc left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There are probably lots of little details about whether we want to expose certain properties on some of the core classes, etc. but overall I think this looks really good for such an ambitious project. The demo is really great proof of this idea.

For the record I was reviewing the code and example code here: flutter/flutter@master...hannah-hyj:flutter:text-plugin-testing

Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
}
```

#### Post-layout lifecycle guarantee (`didAddText`, `didUpdateText`, `didLayoutText`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I mentioned this elsewhere, but I wonder about the performance of how this is done. Ideally we do this in a way that scales with the amount of text on the page.

Comment thread rfc/170.0000-composable-text-plugins.md Outdated

#### Post-layout lifecycle guarantee (`didAddText`, `didUpdateText`, `didLayoutText`)

A key lesson from prototyping is that invoking `didAddText` during `attach()` / `updateRenderObject()` (before layout) or invoking `didUpdateText` inside `set text` (immediately after `markNeedsLayout()`) or invoking `didLayoutText` inside `performLayout()` (before `RenderObject.layout()` clears `_needsLayout = false` and before parent `RenderBox`es position child offsets) creates severe traps for plugin authors:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

severe traps

I wish we had a clear "text layout" phase, and then we could run a secondary layout after that similar to LayoutBuilder. That way it would be very clear when text metrics are available and not.

This is probably beyond the scope of this RFC though. I think the proposal below is reasonable.

Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
| **Mobile drag handle positioning (`LeaderLayer`)** | **No (not via `CustomPainter`)** | Requires `PaintingContext.pushLayer(LeaderLayer(...))` and `alwaysNeedsCompositing` on `RenderParagraph`. |
| **Gesture dispatch and cross-widget selection** | **No** | Handled by `SelectableRegion` and `SelectionRegistrar` across both text and non-text `Selectable` render objects. |

#### 5.2.4 Follow-up roadmap: what it would take to migrate `SelectionArea` to `TextPlugin`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I wonder what @chunhtai thinks of this.

Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Outdated

Domain-specific or third-party-dependent features belong in standalone community packages:

1. `SpellCheckPlugin` (`package:flutter_spellcheck`) renders IME spellcheck squiggly underlines and grammar suggestions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do you think this should be in flutter/flutter or flutter/package? that is - is this a OS behavior or design library behavior

Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Outdated

1. **`didAddText(delegate)`** fires once at the end of `flushLayout()` after a newly attached `(RenderObject, TextPlugin)` pair completes its initial layout.
2. **`didUpdateText(delegate)`** fires at the end of `flushLayout()` when an already-added delegate's text (or text style) changed since the last layout pass.
3. **`didLayoutText(delegate)`** fires at the end of `flushLayout()` when an already-added delegate re-laid out (for example, due to viewport constraint changes) without its text changing. Within a single frame, a delegate receives at most one of `didAddText`, `didUpdateText`, or `didLayoutText`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

at most one of the did* will be called during a flushLayout? The name didLayoutText feels like will be called whenever the text is laid out accompany a didAddText and didUpdateText. maybe rename it like didRelayoutText

However, do you think we need to tell between didUpdateText and didLayoutText, or can they be a single didUpdate?

Comment thread rfc/170.0000-composable-text-plugins.md Outdated

To eliminate these traps, `RenderParagraph` and `RenderEditable` dispatch `didAddText`, `didUpdateText`, and `didLayoutText` at the **post-layout phase** at the end of `PipelineOwner.flushLayout()` (via `PipelineOwner.schedulePostLayoutCallback`), immediately after all dirty `RenderObject`s have finished layout (`_needsLayout == false` and parent `offset`s are finalized) and **before** `PipelineOwner.flushPaint()` runs:

1. **`didAddText(delegate)`** fires once at the end of `flushLayout()` after a newly attached `(RenderObject, TextPlugin)` pair completes its initial layout.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

is TextDelegate a read only interface? we need to make sure that is the case if possible in case people wrote plugin that mutate the provided TextDelegate.


`TextPluginScope` uses an internal `InheritedWidget` (`_InheritedTextPluginScope`) that resolves, deduplicates, and orders active plugins down the widget tree during `build`:

1. **`Type`-based deduplication and inner-scope override**: Like Flutter's `Actions` and `Shortcuts` widgets, `TextPluginScope` deduplicates plugins by their concrete `runtimeType` (`Map<Type, TextPlugin>`). When an inner `TextPluginScope` provides a plugin of type `T`, it overrides any ancestor plugin of the same type `T` for its descendant subtree. This matches `TextPluginScope.exclude(types: {T})` and guarantees that at most one instance of any given `TextPlugin` subclass is active on a `RenderParagraph` or `RenderEditable`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

better if it can be factory to create TextPlugin instead of an TextPlugin object directly.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

that way if TextPlugin need to be dispose, it will be the inheritedwidget's job to do it. and less of a footgun for the developer who create it on the flight

Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Outdated
set caseSensitive(bool value);

bool get eagerLoadOffscreenText => _eagerLoadOffscreenText;
set eagerLoadOffscreenText(bool value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

who gets ownership for this object? if it is mutable the developer will need a statefulWidget to keep a reference to SearchInPagePlugin and pass it into the TextPluginScope.

bascially are you imagine the usage to look like

MyState extends State {
  late SearchInPagePlugin plugin = SearchInPagePlugin();
  
  @override
  void didUpdateWidget(...) {
     plugin.eagerLoadOffscreenText = widget.someFlag;
  }

  @override
  void dispose(...) {
     plugin.dispose();
  }

  @override
  void build(..) {
     return TextPluginScope(
         plugins: {SearchInPagePlugin: plugin}
    
     )
  }
}

or

MyWidget extends StatelessWidget {

  void _createSearchPlugin() {
     return SearchInPagePlugin(eagerLoadOffscreenText: someFlag);
  }

  @override
  void build(..) {
     return TextPluginScope(
         plugins: {SearchInPagePlugin: _createSearchPlugin}
    
     )
  }
}

I can see issue with both though.

if using state, the developer needs to handle its lifecycle and waste of resource if it gets override in the latter plugin scope.

If using factory, we need a way to let TextPluginScope to know when to recreate the plugin even if the factory instance is the same.

Comment thread rfc/170.0000-composable-text-plugins.md Outdated
To solve all four issues:

1. `TextDelegate` tracks a dedicated `_hasValidLayout` boolean (set to `false` in `markNeedsLayout()` and `true` at the end of `performLayout()`), so `delegate.hasLayout` is accurate in both debug and release builds.
2. `RenderParagraph` and `RenderEditable` schedule `didAddText`, `didUpdateText`, and `didLayoutText` via `PipelineOwner.schedulePostLayoutCallback`, which flushes at the end of `PipelineOwner.flushLayout()` after all dirty `RenderObject`s have exited `layout()` and finalized parent offsets, and right before `PipelineOwner.flushPaint()`.

@loic-sharma loic-sharma Nov 5, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are you allowed to invalidate layout or build widgets during PipelineOwner.schedulePostLayoutCallback? (I suspect the answer should be no, and if possible PipelineOwner.schedulePostLayoutCallback should strictly enforce this with assertions)

Comment thread rfc/170.0000-composable-text-plugins.md Outdated
To solve all four issues:

1. `TextDelegate` tracks a dedicated `_hasValidLayout` boolean (set to `false` in `markNeedsLayout()` and `true` at the end of `performLayout()`), so `delegate.hasLayout` is accurate in both debug and release builds.
2. `RenderParagraph` and `RenderEditable` schedule `didAddText`, `didUpdateText`, and `didLayoutText` via `PipelineOwner.schedulePostLayoutCallback`, which flushes at the end of `PipelineOwner.flushLayout()` after all dirty `RenderObject`s have exited `layout()` and finalized parent offsets, and right before `PipelineOwner.flushPaint()`.

@loic-sharma loic-sharma Nov 5, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I assume the post-layout callback is called after the PipelineOwner has also flushed its children PipelineOwners layout? That might be worth stating since that has implications on what layout information is available to you when the post-layout callback is invoked.

@loic-sharma loic-sharma Nov 5, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A potential alternative to might be to introduce a RendererBinding.addPostLayoutCallback method (I changed schedule->add to better mirror RendererBinding.addPostFrameCallback).

RendererBinding.drawFrame() would call this after flushing the root pipeline owner's layout. The benefit of this approach is that it would happen once per frame, instead of once per frame for each render tree.


A single `_SelectionHighlightTextPlugin` installed at the root `SelectableRegion` only knows the default selection color from `DefaultSelectionStyle`. If a descendant `Text('Warning', selectionColor: Colors.red)` overrides its selection color, or if a `Text.rich` contains inline `WidgetSpan`s that split the paragraph into multiple selectable ranges, the plugin cannot render accurately unless `TextDelegate` exposes per-paragraph `selectionColor` and `selections` (where `getBoxesForSelection` defaults to `includePlaceholders: false` so inline widgets are not highlighted).

##### 3. Plugin opt-out (`TextPluginScope.none` versus `TextPluginScope.exclude`)

@loic-sharma loic-sharma Nov 5, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For things like stacked navigation of pages or tabbed content - where widgets are mounted but not visible - will we automatically opt them out of plugins?

Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Resolved
Comment thread rfc/170.0000-composable-text-plugins.md Outdated

Because `didAddText`, `didUpdateText`, and `didLayoutText` run at the end of `PipelineOwner.flushLayout()`, `delegate.hasLayout` is already `true` whenever a delegate is added or updated:

1. Calling `scrollToActiveMatch()` from `nextMatch()` or `previousMatch()` can immediately invoke `current.delegate.ensureVisible(current.range, duration: duration, curve: curve)` because every delegate in `_matches` has already completed layout.

@loic-sharma loic-sharma Nov 5, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the match is in a multi-line text field but the match is not currently visible, you need to scroll the text field itself to make the match visible - will that work? Is that something the TextDelegate (in this case RenderEditable) is responsible for figuring out?

Comment thread rfc/170.0000-composable-text-plugins.md Resolved

`SearchInPagePlugin` is the primary first-party `TextPlugin` motivating this proposal, providing drop-in `Ctrl+F` / `Cmd+F` Find-in-Page across any subtree of `Text`, `RichText`, and `EditableText` widgets.

#### 3.6.1 Public API and `ChangeNotifier` integration

@loic-sharma loic-sharma Nov 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

People might want to heavily customize the search UI. Will we allow that customization? Also I imagine the default search UI depends on Material/Cupertino, will that logic need to live in flutter/packages? What will the API boundary be between the core framework and the design system packages?

Stage 1 (painter only) is the proposal. Stages 2 and 3 are described as future stages with what each experiment showed. Removes the pointer event API, corrects places where the text disagreed with the prototype, drops the mermaid diagrams, and adds sections on plugin ownership, performance, the limits of stage 1, open questions and live demos.
@justinmc

Copy link
Copy Markdown

One feature I just realized we should have for ctrl-f: characters with diacritics in the app text should be matched by the character without the diacritic in the search text. This is how Chrome works today, for example:

Screenshot From 2026-11-12 10-25-58

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants