feat: cross-block & cross-widget text selection (0.2.0) - #26
Merged
Conversation
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.
…umentation updates
… enhance related tests
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>
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>
…d related benchmarks
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 multipleMarkdownWidgets, and even keep the selection alive while aListViewdisposes and re-creates items (chat scrolling). Copy produces either plain text or reconstructed Markdown.Because stock
SelectionArea/SelectableRegioncannot 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 contentui.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 externalSelectableText), the content-anchoredMarkdownReconciliationPolicy(streaming-safe), the sharedmarkdownBlockRenderedTextlinearizer, and theMarkdownSelectionSurfacecontract.Selectable rendering —
MarkdownRenderObjectimplementsMarkdownSelectionSurface, maps pointers to logical positions and paints the highlight outside the cachedPicture(repaint boundary when selectable). AMultiPainterSelectablemixin (+SelectableFragment) extends this across the manyTextPainters 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/\tseparators.Interaction (
lib/src/selection_scope.dart) —MarkdownSelectionScopedrives selection from mouse/trackpad/stylus drag and touch long-press-drag, plus:SelectableRegion:Ctrl/Cmd+Ccopy,Ctrl/Cmd+Aselect-all,Shift+arrows extend by character/word/line/document (and vertically by geometry),Escclear.Shift-click extends; word boundaries use platform segmentation (socan'tstays whole).AdaptiveTextSelectionToolbar, fully customizable throughcontextMenuBuilder.SelectionOverlay; endpoints pushLeaderLayers so handles follow scrolling content across widgets.SelectableText-style params:focusNode,enabled,selectionColor,contextMenuBuilder,magnifierConfiguration,selectionControls,onSelectionChanged.Copy-as-Markdown —
MarkdownMarkupFormatterreconstructs 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).MarkdownWidgetgains optionaldocumentId/controller(resolved from the ambient scope). Fully backward compatible: without adocumentIdthe widget is inert.Supporting changes
lib/src/render.dart(~1750 lines) decomposed intolib/src/render/(block_painter.dart,markdown_painter.dart,markdown_render_object.dart,span_builder.dart, and one file per block type underblocks/).docs/architecture.md,development.md,parser.md,rendering.md,selection.md, and adocs/migration/0.0.x-to-0.2.x.mdguide. README + CHANGELOG updated; version bumped to 0.2.0.benchmark_compare/package comparing parse/render performance offlutter_mdvsflutter_markdownvsgpt_markdown, plus a newselection_dragbenchmark tier proving the highlight adds ~4 µs over a cache hit and never re-records thePicture.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
num.clampbounds for e.g. an empty streaming message).markdownBlockRenderedTextnow joins list items unconditionally to match the painter, so an empty leading/middle item no longer misaligns the highlight or truncates the copy.moveSelectionEdgeToGlobalnow keeps ≥1 caret gap.Overlay/MaterialApphost.Compatibility
0.2.x is almost entirely backward compatible. The only required code change is a new
alertbranch for directMD$Block.map/switchcallers (already shipped in 0.1.0). See the migration guide.Testing
dart format --line-length 80— cleandart analyze --fatal-infos --fatal-warnings lib/ test/— no issuesflutter test test/unit_test.dart— 459 unit tests green (selection: core, widget integration, keyboard, handles, markup formatter)flutter testinexample/— smoke test greenpaint_hit~41 µs,selection_drag~45 µs)🤖 Generated with Claude Code