Skip to content

feat: cross-block & cross-widget text selection (0.2.0) - #26

Merged
mike-doctorina merged 30 commits into
masterfrom
feat/text-selection
Aug 5, 2026
Merged

feat: cross-block & cross-widget text selection (0.2.0)#26
mike-doctorina merged 30 commits into
masterfrom
feat/text-selection

Conversation

@mike-doctorina

@mike-doctorina mike-doctorina commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds cross-block and cross-widget text selection to flutter_md — the headline feature of the upcoming 0.2.0 release. A user can now drag-select across paragraphs, lists, tables, code fences and quotes, across multiple MarkdownWidgets, and even keep the selection alive while a ListView disposes and re-creates items (chat scrolling). Copy produces either plain text or reconstructed Markdown.

Because stock SelectionArea/SelectableRegion cannot do this (Phase-1 spikes documented the glue/disposal defects and the private scrollable delegate that hides list items), the selection is anchored on the immutable Markdown model rather than on mounted render objects. Logical anchors make text extraction mount-independent, and the highlight is drawn outside the cached content ui.Picture, so selection and drag repaints never rebuild the glyph cache.

Closes #25. Builds on the render refactor tracked alongside it.

What's new

Selection core (lib/src/selection.dart)MarkdownSelectionController (anchors on the model, spans blocks/widgets, survives disposal), MarkdownPosition / MarkdownSelection, MarkdownDocumentRef, MarkdownSelectedContent, MarkdownSelectionGroup (only one selection active at a time; also resets external SelectableText), the content-anchored MarkdownReconciliationPolicy (streaming-safe), the shared markdownBlockRenderedText linearizer, and the MarkdownSelectionSurface contract.

Selectable renderingMarkdownRenderObject implements MarkdownSelectionSurface, maps pointers to logical positions and paints the highlight outside the cached Picture (repaint boundary when selectable). A MultiPainterSelectable mixin (+ SelectableFragment) extends this across the many TextPainters of a list's items and a table's cells, so a drag can start/end inside a list item or table cell and copied text keeps the \n / \t separators.

Interaction (lib/src/selection_scope.dart)MarkdownSelectionScope drives selection from mouse/trackpad/stylus drag and touch long-press-drag, plus:

  • Keyboard, mirroring SelectableRegion: Ctrl/Cmd+C copy, Ctrl/Cmd+A select-all, Shift+arrows extend by character/word/line/document (and vertically by geometry), Esc clear.
  • Granular gestures: double-click/tap selects a word, triple selects the block, single click collapses, Shift-click extends; word boundaries use platform segmentation (so can't stays whole).
  • Context toolbar (right-click / long-press) via an adaptive AdaptiveTextSelectionToolbar, fully customizable through contextMenuBuilder.
  • Native handles + magnifier on touch platforms, driven by SelectionOverlay; endpoints push LeaderLayers so handles follow scrolling content across widgets.
  • Cursor feedback: hand over links, I-beam while selectable, default otherwise.
  • New SelectableText-style params: focusNode, enabled, selectionColor, contextMenuBuilder, magnifierConfiguration, selectionControls, onSelectionChanged.

Copy-as-MarkdownMarkdownMarkupFormatter reconstructs Markdown structure on copy (heading #s, nested list markers with task checkboxes, > prefixes, fenced code, pipe tables) for fully-covered blocks, falling back to sliced plain text on partial boundary blocks. The default copy behaviour is unchanged (MarkdownPlainTextFormatter).

MarkdownWidget gains optional documentId / controller (resolved from the ambient scope). Fully backward compatible: without a documentId the widget is inert.

Supporting changes

  • Render layer splitlib/src/render.dart (~1750 lines) decomposed into lib/src/render/ (block_painter.dart, markdown_painter.dart, markdown_render_object.dart, span_builder.dart, and one file per block type under blocks/).
  • Docs — new docs/architecture.md, development.md, parser.md, rendering.md, selection.md, and a docs/migration/0.0.x-to-0.2.x.md guide. README + CHANGELOG updated; version bumped to 0.2.0.
  • Benchmarks — a benchmark_compare/ package comparing parse/render performance of flutter_md vs flutter_markdown vs gpt_markdown, plus a new selection_drag benchmark tier proving the highlight adds ~4 µs over a cache hit and never re-records the Picture.
  • Example — reworked into tabs: the existing live Editor, a Selection tab spanning every block type across two grouped controllers + a plain SelectableText, and a Chat tab with real token-by-token streaming, typing indicator, auto-stick-to-bottom, and cross-message selection through scroll/disposal.

Adversarial review fixes folded in

  • Crash when dragging near a zero-size selection surface (inverted num.clamp bounds for e.g. an empty streaming message).
  • List offset drift: markdownBlockRenderedText now joins list items unconditionally to match the painter, so an empty leading/middle item no longer misaligns the highlight or truncates the copy.
  • Handle drag that collapsed the selection onto itself disposed the live overlay mid-gesture — moveSelectionEdgeToGlobal now keeps ≥1 caret gap.
  • Guards for controller swap (stale handle leaders) and for running without an Overlay/MaterialApp host.

Compatibility

0.2.x is almost entirely backward compatible. The only required code change is a new alert branch for direct MD$Block.map / switch callers (already shipped in 0.1.0). See the migration guide.

Testing

  • dart format --line-length 80 — clean
  • dart analyze --fatal-infos --fatal-warnings lib/ test/ — no issues
  • flutter test test/unit_test.dart459 unit tests green (selection: core, widget integration, keyboard, handles, markup formatter)
  • flutter test in example/ — smoke test green
  • Render benchmark shows no regression (paint_hit ~41 µs, selection_drag ~45 µs)

🤖 Generated with Claude Code

PlugFox and others added 24 commits July 30, 2026 17:19
Phase 1 de-risking spikes (benchmark/experiments/s1..s7 + FINDINGS.md) prove
that stock SelectionArea cannot do cross-block/cross-widget Markdown selection
(glue + disposal defects; a ListView hides items behind a private scrollable
delegate), and validate a controller-anchored design: logical anchors over the
immutable model make text extraction mount-independent (survives disposal),
with the highlight drawn outside the cached ui.Picture (paint_hit ~160x cheaper).

Adds a flutter test-driven render benchmark (benchmark/render_benchmark.dart)
mirroring compare.dart's min-of-batches baseline.

Locks the substrate-agnostic core in lib/src/selection.dart (exported):
MarkdownSelectionController, MarkdownPosition/Selection, MarkdownDocumentRef,
MarkdownSelectedContent, a public MarkdownSelectionFormatter (+ default),
content-anchored MarkdownReconciliationPolicy (no model id), the shared
markdownBlockRenderedText linearizer, and MarkdownSelectionSurface. 12 unit
tests; full suite 383 green, dart format + analyze --fatal-infos clean.

Issue #25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- render.dart: MarkdownRenderObject now implements MarkdownSelectionSurface,
  maps pointers to logical positions, and paints the selection highlight
  OUTSIDE the cached content ui.Picture (isRepaintBoundary when selectable).
  A SelectableBlockPainter interface + SelectableTextBlock mixin add selection
  to the paragraph/heading/quote/alert/code painters; MarkdownPainter tracks
  source block indices (for blockFilter) and exposes positionForLocal /
  paintHighlight.
- selection.dart: MarkdownSelectionGroup coordinates multiple controllers (and
  external SelectableText via clearExternal) so only one selection is active;
  positionForGlobal falls back to the nearest surface so drags through the gaps
  between widgets still extend.
- selection_scope.dart: MarkdownSelectionScope drives selection from a
  mouse/trackpad/stylus drag (DragStartBehavior.down) or touch long-press-drag.
- widget.dart: MarkdownWidget gains optional documentId/controller (resolved
  from the ambient scope); inert and fully backward compatible when absent.

5 widget integration tests verify single-paragraph, cross-widget, disposal
survival, group reset, and inert-without-documentId. Full suite 388 green,
format + analyze --fatal-infos clean. Issue #25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructures the example into three tabs:
- Editor: the existing split-pane live editor (unchanged behaviour).
- Selection: cross-block selection in a MarkdownWidget, two independent
  controllers that reset each other via a shared MarkdownSelectionGroup, and a
  plain SelectableText that participates in the reset (both directions).
- Chat: a ListView.builder of message bubbles with cross-message selection
  through one controller (survives scroll/disposal), a Copy button, and a
  Stream button that grows the last message to show reconciliation.

Adds example/test/smoke_test.dart verifying every tab builds and a drag-select
does not throw. Issue #25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
README gains a Text Selection section (controller, scope, group, formatter,
streaming reconcile, gestures) and a Features bullet; CHANGELOG 0.2.0 lists the
new public API. Issue #25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review found that positionForGlobal's nearest-surface fallback
inverted num.clamp's limits (right-0.01 < left) for a zero-size surface — e.g. a
MarkdownWidget(markdown: Markdown.empty()) (a streaming chat message before any
tokens) or a spacer/divider-only document — throwing ArgumentError inside the
drag/long-press gesture callback. Skip zero-area surfaces and clamp defensively.
Two regression tests cover drag-with-only-empty-doc and drag-past-empty-doc.

Also documents that a theme spanFilter dropping text-bearing spans shifts the
painter offset space vs the unfiltered model (highlight stays correct, copied
text may misalign) — avoid it with selection enabled.

Issue #25.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Library:
- Add MultiPainterSelectable mixin (+ SelectableFragment) that maps pointer
  positions and highlight boxes across the many TextPainters of a list's items
  or a table's cells, and apply it to BlockPainter$List / BlockPainter$Table.
  Lists and tables are now interactively selectable; a drag can start or end
  inside an item/cell and copied text keeps the \n / \t separators that
  markdownBlockRenderedText produces. Fragment lists cleared on dispose.

Tests:
- Widget drags that select a table, a list, and a selection spanning a table.
- Linearization asserts for nested and task lists.

Example:
- Chat tab: longer, varied conversation (tables, code, nested/task lists,
  quotes, GitHub alerts, math), real token-by-token streaming with a typing
  indicator and auto-stick-to-bottom, Select-all/Clear controls, avatars.
- Selection tab: spans every block type (heading, quote, nested list, table,
  code, alert) across two grouped controllers plus a plain SelectableText.

Docs: CHANGELOG + README note full block-type selection coverage.

Verified: dart analyze --fatal-infos + format clean on lib/ test/; 393 unit
tests green; example smoke green; render benchmark shows no regression
(paint_hit ~41us, deterministic tiers within +/-2%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror SelectableRegion/SelectableText for the Markdown selection scope:

- MarkdownSelectionScope is now a StatefulWidget with a public
  MarkdownSelectionScopeState. It owns a FocusNode + Actions map so that,
  when focused, the ambient DefaultTextEditingShortcuts drive: Ctrl/Cmd+C
  copy, Ctrl/Cmd+A select-all, Shift+arrows extend (character/word/line/
  document + vertical-by-geometry, honoring intent.collapseSelection so bare
  arrows don't fire), Esc clear.
- Right-click (desktop) / long-press (mobile) shows an adaptive context
  toolbar via ContextMenuController + AdaptiveTextSelectionToolbar. Fully
  customizable through contextMenuBuilder; state exposes contextMenuButtonItems
  / contextMenuAnchors / showToolbar / hideToolbar / copySelection / selectAll
  / clearSelection.
- New customization params: focusNode, enabled, selectionColor,
  contextMenuBuilder, magnifierConfiguration, selectionControls,
  onSelectionChanged (magnifier/handles params are wired in the next commit).
- Controller/render additions: customizable selectionColor (per-instance
  highlight paint), globalSelectionRects() + MarkdownSelectionSurface.
  globalSelectionRects(), moveSelectionEdgeToGlobal(), the extendSelectionBy*
  family, MarkdownPosition.copyWith, and MarkdownPainter.selectionBoxes().

Tests: 6 new keyboard/toolbar widget tests (Ctrl+A/C, Esc, Shift+Arrow,
right-click toolbar, state-driven show/hide). 399 unit tests green;
dart analyze --fatal-infos + format clean on lib/ test/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add touch-platform selection handles and a magnifier, mirroring
SelectableText/SelectionArea, driven from the logical-model controller:

- Render objects push start/end LeaderLayers at the selection endpoints
  (MarkdownRenderObject.setSelectionHandleLayers + paint), so the scope's
  SelectionOverlay handles follow the content as it scrolls and across
  multiple MarkdownWidgets. New surface geometry: localSelectionRects,
  setSelectionHandleLayers, repaintSelection.
- Controller resolves the two endpoints (owning surface + local/global caret
  rects) via selectionHandleEndpoints() / MarkdownHandleEndpoints.
- The scope owns a SelectionOverlay + 3 LayerLinks, assigns leaders to the
  owning surfaces each selection change (single-pass, no needless repaints),
  and wires handle-drag -> moveSelectionEdgeToGlobal + magnifier show/update/
  hide. Handles/magnifier use platform-default TextSelectionControls +
  adaptive magnifier (customizable), and are gated to touch platforms.
- Overlay sync is deferred to a post-frame callback when it would run during
  a build/layout phase (e.g. a streaming setState).

Fix: selectionColor now repaints mounted surfaces directly instead of calling
notifyListeners (it is a render detail, and is applied from
didChangeDependencies where a listener's setState would throw).

Tests: 3 handle tests (edge-drag adjusts selection; touch shows handles;
desktop shows none). 402 unit tests green; analyze --fatal-infos + format
clean; example smoke green; benchmark shows no regression (paint_hit ~41us).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- benchmark: add the selection_drag tier (deferred since spike S7). It grows a
  highlight over the cached content Picture each frame and asserts the drag
  frame stays below paint_miss/3 — measured ~45us vs paint_hit ~41us, i.e. the
  highlight adds ~4us over a cache hit and never re-records the Picture. The
  path is already optimal, so no code change was needed.
- example: the Selection tab now demonstrates customization — a custom
  contextMenuBuilder adding a "Copy LOUD" action on doc A, and a custom
  selectionColor on doc B. Chat/Selection hint text mentions the keyboard
  shortcuts, right-click/long-press toolbar and touch handles.
- README: document native handles/magnifier/toolbar, keyboard shortcuts and the
  SelectableText-style customization params, with a contextMenuBuilder snippet.

402 unit tests green; analyze --fatal-infos + format clean; example smoke green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Skip creating the SelectionOverlay and showing the context toolbar when the
scope is not under an Overlay (e.g. used without a MaterialApp), avoiding a
hard failure on touch platforms. Selection, highlight and keyboard still work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… ctrl swap

Adversarial review of the new selection code surfaced three issues:

1. (correctness) markdownBlockRenderedText joined list items conditionally
   (`if (buffer.isNotEmpty)`) while the painter joins every item with '\n'
   unconditionally, so a list with an empty leading/middle item shifted the
   model offset space vs the painter — wrong highlight and truncated copy.
   Linearize lists as a plain depth-first join('\n') to match the painter.
   Regression test added (`- [ ]\n- [x] Done` -> "\nDone").

2. (ux) A handle drag that dragged one handle exactly onto the other collapsed
   the selection and disposed the live SelectionOverlay mid-gesture (drag went
   dead). moveSelectionEdgeToGlobal (handle-only) now refuses a move that would
   collapse, keeping >=1 caret gap.

3. (robustness) didUpdateWidget controller swap left stale handle leaders /
   overlay bound to the old controller; now clears handles and resyncs.

402 unit tests green; analyze --fatal-infos + format clean; example smoke green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd widget implementations

- Standardized line breaks and indentation in various test files including block_test.dart, edge_cases_test.dart, gfm_test.dart, inline_test.dart, math_test.dart, parser_test.dart, regression_test.dart, selection_handles_test.dart, selection_keyboard_test.dart, selection_test.dart, selection_widget_test.dart, theme_test.dart, and render_test.dart.
- Improved readability by aligning code and removing unnecessary line breaks.
- Ensured consistent use of method chaining and parameter formatting.
… files and widget implementations

- Adjusted line breaks and indentation for better readability in `widget.dart`, `block_test.dart`, `edge_cases_test.dart`, `gfm_test.dart`, `inline_test.dart`, `math_test.dart`, `parser_test.dart`, `regression_test.dart`, `selection_handles_test.dart`, `selection_keyboard_test.dart`, `selection_test.dart`, `selection_widget_test.dart`, `theme_test.dart`, and `render_test.dart`.
- Ensured consistent formatting in test cases, including spacing and line length.
- Enhanced clarity of assertions and expectations in tests.
…parser, rendering, and selection layers

- Introduced architecture.md detailing the four-layer structure and data flow of `flutter_md`.
- Added development.md outlining commands, CI pipeline, lint rules, conventions, and repo layout.
- Created parser.md explaining the parser and node model, entry points, and GFM support.
- Added rendering.md describing the rendering layer, block painter framework, and theme customization.
- Introduced selection.md detailing text selection architecture, controller, surfaces, and interaction.
- Introduced `benchmark_compare` package to evaluate parsing and rendering performance of `flutter_md`, `flutter_markdown`, and `gpt_markdown`.
- Implemented parser benchmark in `parser_benchmark.dart` to measure parsing times.
- Created render benchmark in `render_benchmark_test.dart` to assess end-to-end rendering performance.
- Added integration test for scroll performance in `scroll_perf_test.dart`.
- Developed utility scripts for summarizing benchmark results and formatting output.
- Established shared corpus and styles for consistent benchmarking across libraries.
- Configured analysis and devtools options for the benchmark package.
…e feedback

- Added word- and block-granular selection gestures: double-click selects a word, triple-click selects a block, single click clears selection, and Shift-click extends it.
- Implemented dragging after double/triple clicks to maintain word/block granularity; touch long-press selects a word and extends by word.
- Introduced mouse cursor feedback: hand cursor over actionable links, I-beam cursor during selection, and default cursor otherwise.
- Updated selection highlight rendering to paint on top of glyphs for better visibility over opaque backgrounds.
- Reworked selection gestures to support multi-tap and extend selection with Shift-click.
- Added tests for word boundaries and selection gestures to ensure expected behavior.
CI's "Check code format" step failed on feat/text-selection because
test/selection/selection_widget_test.dart and
test/selection/markup_formatter_test.dart were not formatted to the
80-column limit, which short-circuited the analyzer, unit-test and
example-test steps. Reflow both files with `dart format --line-length 80`
(whitespace only, no logic changes) so the pipeline goes green again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mike-doctorina mike-doctorina changed the title Feat/text selection feat: cross-block & cross-widget text selection (0.2.0) Aug 4, 2026
PlugFox and others added 4 commits August 4, 2026 16:30
CI runs on a newer Flutter stable where ListView.cacheExtent was renamed
to scrollCacheExtent (deprecated after v3.41.0-0.0.pre), so
`dart analyze --fatal-infos` failed on the one remaining reference. The
replacement property does not exist on the package's supported floor
(flutter: >=3.29.0), so renaming would break older SDKs.

Remove the `cacheExtent: 0` instead: after jumping the ListView to
offset 560, item m0 (px 0-80) sits ~480px from the viewport — well beyond
the default 250px cache extent — so it is still disposed and the
`findsNothing` assertion that the test relies on continues to hold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Implemented a new syntax highlighting engine in `lib/src/highlight/engine.dart` that utilizes Prism.js language definitions.
- Enhanced `MarkdownThemeData` to include an optional `highlighter` for fenced code blocks.
- Updated `BlockPainter$Code` to support rendering highlighted code using the new highlighter.
- Added tests for the syntax highlighter in `test/highlight/highlight_test.dart` to ensure lossless text rendering and correct token coloring.
- Introduced a code generation tool in `tool/prism_codegen` to convert Prism.js grammars into Dart code, allowing for tree-shakeable imports.
- Added support for multiple languages and their aliases in the grammar generation process.
- Updated the main test suite to include highlight tests.
@mike-doctorina
mike-doctorina merged commit bc4fad9 into master Aug 5, 2026
1 check passed
@mike-doctorina
mike-doctorina deleted the feat/text-selection branch August 5, 2026 14:53
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.

Selectable rendering: cross-block and cross-widget text selection with a controller

2 participants