Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5cf8261
feat(selection): SelectionArea-parity chrome, registry fixes, and aut…
itsezlife Sep 11, 2026
a41636f
feat: add clampMarkdownToLines for painter-measured line budgets
itsezlife Sep 11, 2026
1686337
chore(example): bump Flutter/SDK tooling and refresh demo tabs
itsezlife Sep 11, 2026
8768102
feat(render): add cursorResolver hook and localBoxesForRange query
itsezlife Sep 12, 2026
72575be
feat(selection): align desktop toolbar and right-click context menu w…
itsezlife Sep 12, 2026
ab9dc9b
feat(selection): add enableTouchGestures parameter to MarkdownSelecti…
itsezlife Sep 13, 2026
93a8791
feat(selection): surface vs glyph hit-testing and fenced chrome links
itsezlife Sep 13, 2026
981bfaf
fix: nest fenced code in quotes/alerts and keep multi-scope handles
itsezlife Sep 14, 2026
dcc4f3a
chore: dart formatting
itsezlife Sep 14, 2026
52b4eb2
fix(selection): scroll-protocol-agnostic autoscroll + registry and ga…
PlugFox Sep 14, 2026
b44fde7
fix(selection): never call onSelectionChanged from inside build or la…
PlugFox Sep 14, 2026
51dc430
feat(selection): opt out of the host-union autoscroll gate
PlugFox Sep 14, 2026
bbd9d29
test(selection): cover line-clamp and controller-lifecycle corner cases
PlugFox Sep 14, 2026
2138976
fix(render): route nested quote/alert children through the theme builder
PlugFox Sep 14, 2026
e78a591
fix(theme): resolve the monospace family per platform, not Apple-first
PlugFox Sep 14, 2026
3a65b05
feat(theme): make the monospace face replaceable
PlugFox Sep 14, 2026
879bd1c
test(parser): stream fenced code inside quotes and alerts
PlugFox Sep 14, 2026
2868f77
chore: split the line clamp and the monospace font out of this PR
PlugFox Sep 14, 2026
241761b
style: dart format
PlugFox Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ Public API is the barrel `lib/flutter_md.dart` (`export … show …`). See
offsets (highlight stays right, copied text drifts). Avoid with selection on.
- `MarkdownSelectionController.selectionColor` setter repaints surfaces directly
and must **not** `notifyListeners` (it's applied during build → would `setState`).
- `removeDocument` while a surface is mounted is deferred until `detachSurface`;
`putDocument` cancels a pending remove. Unregistered selection endpoints must
not paint via `-1` ordering — `rangeFor` returns null. Supply unique `order`
values for co-hosted bodies.
- Alert **title** is not selectable (body only). Keyboard word/line extension is
block-approximate. `MarkdownSelectedBlock.sourceRange` is currently always null.
- `__x__` = **underline**, not bold. Soft line breaks are preserved inside
Expand Down
222 changes: 222 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,225 @@
## Unreleased

### Breaking vs 0.2.0

Source-breaking only for hosts that implement the render / selection
extension points or reach into a default block painter's fields. Ordinary
`MarkdownWidget` / `MarkdownSelectionScope` users are unaffected.

- **REMOVED**: `BlockPainter$Quote.painter` and `BlockPainter$Alert.bodyPainter`.
Both painters now stack nested children, so a single body `TextPainter` no
longer exists. Read `renderedText` / `fragments` (from
`MultiPainterSelectable`) instead.
- **CHANGED**: `BlockPainter$Quote` and `BlockPainter$Alert` mix in
`MultiPainterSelectable` instead of `SelectableTextBlock`. Subclasses that
overrode `selectionPainter` / `selectionOrigin` must move to `fragments`.
- **CHANGED**: `SelectableBlockPainter` gained `positionAndAffinityForLocal`,
`caretRectFor`, `hitsRenderedTextAt` and
`selectionHighlightAboveCachedContent`. Custom painters built on the
`SelectableTextBlock` / `MultiPainterSelectable` mixins inherit working
implementations; painters that `implements SelectableBlockPainter` directly
must add all four.
- **CHANGED**: `MarkdownSelectionSurface` gained `hitForGlobal`,
`hitsSelectableGlyphs`, `isLinkAtGlobal`, `caretRectFor`,
`localBoxesForRange`, `hasSelectionHandleLeaders` and
`clearSelectionHandleLayersIfLinked`. Custom surfaces must implement them.
- **CHANGED**: `MarkdownThemeData.copyWith` returns `MarkdownThemeData` instead
of `ThemeExtension<MarkdownThemeData>` (callers gain, overriders must narrow).
- **CHANGED**: `MarkdownRenderObject.updateSelection` takes an optional
`markdown:`. `@meta.internal`, but `MarkdownWidget` subclasses that call it
must now invoke it **before** `update` (see the registry fix below).

### Quote / alert nested fenced code
- **FIXED**: Fenced code (` ```lang ` / `~~~`) inside a blockquote or GitHub
alert is re-parsed as nested `MD$Code` in `MD$Quote.blocks` /
`MD$Alert.blocks`, instead of being consumed as inline monospace by backtick
pairing (` `` ` + monospace + ` `` `).
- **ADDED**: Optional `blocks` on `MD$Quote` / `MD$Alert` (empty for leaf
inline bodies). Render and `markdownBlockRenderedText` walk nested children
when present.
- **FIXED**: Prose nested inside a quote (paragraphs/lists/headings beside a
fence) uses `quoteStyle` again; code/table chrome keeps the document theme.
- **FIXED**: Nested children route through `MarkdownThemeData.builder` like
top-level blocks. A host that replaces the code painter (a fence with a copy
button, say) was silently getting the default one inside `> …`.

### Glyph-tight hit testing
- **CHANGED**: Hover I-beam and link hit-testing use rendered **line/glyph
boxes**, not the full max-width layout of a paragraph. Empty horizontal
gutter beside a short line is no longer I-beam / click-to-open.
- **CHANGED**: Gesture *starts* (`hitsSelectableContent`) stay **surface-
bounds** (press inside the document/bubble arms text selection — tdesktop
`PointState::Inside` parity). Drag-extend may still clamp outside glyphs.
- **CHANGED**: Mouse single-click that misses selectable content while a
non-collapsed range is active clears the selection (tdesktop empty
Selecting / dismiss parity), instead of leaving the old range.
- **ADDED**: `SelectableBlockPainter.hitsRenderedTextAt`,
`MarkdownSelectionSurface.hitsSelectableGlyphs`, and
`MarkdownSelectionController.hitsSelectableGlyphs`.
- **CHANGED**: Glyph boxes are cached per `TextPainter` layout. They are read on
every hover event (I-beam / link cursor); recomputing
`getBoxesForSelection` over a whole block allocated one box per run per line
on every mouse move (thousands of rects over a long fenced code block).

### Selection host gates
- **ADDED**: `MarkdownSelectionScope.canStartSelectionAt` — optional host gate
so enclosing UIs can refuse selection starts on chrome (links, code headers)
without the scope claiming the pointer. A refusal also un-arms the drag: the
recognizers still join the arena while a range is live (so a chrome tap can
dismiss it), and without this a long press / drag on refused chrome kept
extending the active selection from that point.
- **ADDED**: `MarkdownSelectionScope.enableTouchGestures` — when false, touch /
stylus / trackpad selection recognizers stay off; mouse multi-click, handles,
toolbar, and keyboard remain. Focus loss also does not clear the range (host
viewport may steal focus).
- **ADDED**: `MarkdownSelectionScope.enableTouchConsecutiveTaps` — when false
(with `enableTouchGestures` true), touch long-press → word → drag-extend
still arms, but touch multi-tap / horizontal-drag selection does not. Chat
hosts use this so taps stay with the viewport while continuous text entry
works. Focus loss also does not clear the range in that mode.
- **ADDED**: `MarkdownSelectionScope.ownsSelectionChrome` — optional host gate
so only the scope that owns the selection’s document paints handles/toolbar
when several scopes share one controller (chat per-body mounts).
- **FIXED**: Disabled / non-owning sibling scopes no longer clear handle
leaders on every shared-controller surface. Clearing is scoped to leaders
that reference that scope’s own `LayerLink`s, so chat dual mounts keep
handles after non-collapsed range commits (including repeated handle-drag
settles).
- **ADDED**: `MarkdownSelectionSurface.hasSelectionHandleLeaders`,
`clearSelectionHandleLayersIfLinked`, and
`MarkdownSelectionScopeState.selectionHandleLeadersAttached` for observing
coherent handle-leader attachment across multi-scope hosts.
- **CHANGED**: Flipping `enabled` from false → true with an existing
non-collapsed range restores handles; when `toolbarWanted` is set, restores
the toolbar. While disabled, the scope stays inert for chrome (no toolbar /
handles) but keeps `toolbarWanted` for that restore path.

### Dynamic cursor resolution & span bounding boxes
- **ADDED**: `MarkdownThemeData.cursorResolver` and
`MarkdownWidget.cursorResolver` (`MarkdownCursorResolver`) — unopinionated
hook to dynamically resolve hover mouse cursors per local offset, block
index, and block model, falling back to default link/text/defer cursors when
returning null.
- **ADDED**: `MarkdownSelectionSurface.localBoxesForRange` and
`MarkdownPainter.localBoxesForRange` — fast query returning content-local
bounding boxes for an arbitrary character range within a block, directly from
cached block painters without re-layout.

### Registry / multi-body selection
- **FIXED**: `removeDocument` defers while a surface for that id is mounted
and flushes on `detachSurface`; a later `putDocument` cancels the pending
remove. Parent `State.dispose` can run before child detach — eager remove
left hittable surfaces with no registry entry (`rangeFor` / ordering broken).
- **FIXED**: `rangeFor` and endpoint ordering ignore unregistered document ids
instead of treating them as index `-1` (which painted every body from the
start of the registry through the other endpoint).
- **CHANGED**: `MarkdownRenderObject` heals with `putDocument` on attach /
when the controller is wired while already attached, so a mounted selectable
surface is never missing from the registry. Prefer explicit app registration
for unmounted docs and unique reading-order `order` values.
- **FIXED**: Recycling a `MarkdownWidget` element onto a different `documentId`
(a virtualized chat list reusing a slot) overwrote the **outgoing** document's
registry model with the incoming body. `MarkdownWidget` now rewires the
selection registry before pushing the new model, and
`MarkdownRenderObject.updateSelection` takes the incoming `markdown:` so the
heal lands on the right id.
- **FIXED**: `putDocument` without an explicit `order` appended at `_docs.length`
which sorted **ahead** of sparse explicit orders (chat hosts key `order` on
the message id), so a healed body jumped to the front of the conversation and
reversed extracted text. It now sorts after every registered document.
- **FIXED**: Documents sharing an `order` could swap places on any re-sort
(`List.sort` is not stable), silently reversing extracted text. Ties now break
on registration sequence.
- **FIXED**: `MarkdownSelectionScope.onSelectionChanged` fired synchronously
from inside build / layout — a streaming `putDocument` reconciling the range
away (from `MarkdownWidget.updateRenderObject`) or a deferred
`removeDocument` flushing from `RenderObject.detach` threw
"setState() called during build" in any host that rebuilds from the callback.
Delivery is now coalesced to the end of the frame in those phases.

### Selection chrome (SelectionArea parity)
- **ADDED**: Read-only selection chrome on the controller /
`MarkdownSelectionScope` path brought up to Flutter `SelectableRegion` /
`SelectionArea` quality without remounting SelectionArea:
- Content-gated touch `TapAndHorizontalDragGestureRecognizer` + long-press
(consecutive taps, no `DoubleTapGestureRecognizer` arena delay); mouse
`TapAndPanGestureRecognizer`.
- Gesture **starts** require a hit on mounted selectable markdown (chrome /
empty space do not nearest-neighbor clamp); clamp remains for **extend**
across gaps.
- Soft-wrap affinity on handles; directed base/extent edges with reverse
handle types; coincident-caret separation; handle proxies when an endpoint
surface unmounts (virtualization).
- Native `SelectionOverlay` handles + magnifier on touch; LeaderLayer follow
across scroll / multi-widget hosts.
- Keyboard Copy / Select-all / Shift-extend / Esc; adaptive Copy / Select-all
toolbar with live re-anchoring.
- Word / block granular multi-tap and long-press; link hand cursor / I-beam on
selectable content.
- **CHANGED**: Desktop selection toolbar parity with stock Flutter:
- On desktop (`macOS`, `Linux`, `Windows`), mouse drags, double/triple clicks,
keyboard shortcuts (`Cmd/Ctrl+A`), and programmatic selection updates
do not pop up the toolbar.
- Right-click on desktop shows the context toolbar at the click coordinates
without mutating the active selection (no select-word / caret collapse).
- Desktop context menu preserves its right-click anchor when triggering
actions such as "Select all", rather than jumping to selection endpoints.
- Desktop context menu dismisses immediately upon scroll.
- On mobile (`Android`, `iOS`), touch gestures and programmatic selection
(`selectAll`, `selection = ...`) continue to present the adaptive toolbar
and handles with action items.

### Autoscroll is scroll-protocol agnostic
- **ADDED**: `MarkdownAutoscrollTarget` — the scroll surface autoscroll drives,
behind three members (`viewport`, `canScroll`, `applyScrollDelta`). Deltas are
**screen-space content movement** (positive moves content up), never scroll
offsets, so reverse axes and inverted anchors are the adapter's problem.
- **ADDED**: `MarkdownScrollableAutoscrollTarget` — the built-in sliver adapter
(`ScrollableState` / `ScrollPosition`), used when no resolver is configured.
Handles `AxisDirection.up` / `.left` sign flipping.
- **ADDED**: `MarkdownSelectionAutoscrollConfig.targetResolver`
(`MarkdownAutoscrollTargetResolver` + `MarkdownAutoscrollRequest`) so a host
with its own scroll implementation — an anchored chat viewport, a `RenderBox`
that positions children itself, a transform canvas — can be driven without a
`Scrollable` anywhere in the tree. Plus `MarkdownCallbackAutoscrollTarget` and
`MarkdownAutoscrollViewport` (global bounds + band inset) for closure hosts.
- **CHANGED**: `applyMarkdownSelectionAutoscroll` takes an optional `target:`;
`context:` is now optional and only used to resolve the built-in sliver
target. Band, host-union gate and arming logic no longer reference
`ScrollPosition` at all.
- **FIXED**: The scroll surface is resolved **once per drag** and cached. It
used to walk the whole element subtree twice on every autoscroll frame (to
find the surface's element, then the nearest `Scrollable`).
- **ADDED**: `MarkdownSelectionAutoscrollConfig.useHostUnionGate` (default
`true`, the existing behaviour). Turn it off when the markdown bodies **are**
the scrolling content and the host builds only what is visible: such a host's
mounted union is barely larger than the viewport, so the gate would veto a
drag that should keep paging through history. The target's `canScroll` and
the delta it reports applying are then the only stops.
- **ADDED**: `MarkdownSelectionAutoscrollConfig.copyWith`, `==` / `hashCode`.

### Selection engine rework
- **ADDED**: Edge-zone autoscroll while dragging (body / handle / long-press)
near the padded viewport — host-union hard-stop, direction arming gate,
past-viewport max velocity while the union still allows that direction
(`MarkdownSelectionScope.autoscroll`, default `edgeZone: 48`).
- **CHANGED**: Toolbar hides while expanding (body or handle drag) and may
re-show on drag end; anchors recompute on selection change, host/ancestor
scroll, and mounted-surface layout change.
- **CHANGED**: Toolbar placement — both endpoints in clip use stock
above-preferring anchors; **bottom-only** endpoint prefers below that caret;
neither endpoint in clip (tall mid-viewport) top-pins so the below-fallback
cannot sink to the host bottom; empty intersection hides the overlay while
`toolbarWanted` restores on scroll-back / remount.
- **FIXED**: Selection highlight stays outside the glyph `Picture` cache —
under glyphs by default (sharp text), with a second pass **above** opaque
chrome (`selectionHighlightAboveCachedContent`) for code fences, table zebra
rows, and inline monospace / highlight backgrounds
(`markdownSpansPaintOpaqueBackground`).
- **FIXED**: Focus loss does not clear selection while a pointer drag is active
(list rebuilds during autoscroll); geometry walks defer off `performLayout`
(`sizeAccessAllowed`).

## 0.2.0

> **Upgrading from 0.0.x?** See the
Expand Down
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ final MarkdownSelectedContent structured = controller.selectedContent();
- **Streaming stays anchored.** Call `controller.putDocument(id, newModel)` when
a message grows; the default `MarkdownReconciliationPolicy.contentAnchored`
keeps the selection (append fast-path, else relocate by content, else clamp).
Prefer unique reading-order `order` values. `removeDocument` waits for surface
detach when the body is still mounted; a remount `putDocument` cancels a
pending remove.
- **One selection at a time.** Share a `MarkdownSelectionGroup` between
controllers so selecting in one clears the others; call `group.clearExternal()`
when a plain `SelectableText`/`SelectionArea` starts its own selection.
Expand All @@ -273,6 +276,18 @@ final MarkdownSelectedContent structured = controller.selectedContent();
public `MarkdownSelectionScopeState` exposes `copySelection` / `selectAll` /
`clearSelection` / `showToolbar` / `contextMenuButtonItems` /
`contextMenuAnchors` for a fully custom menu.
- **Host gates for embedded mounts.** A chat that owns its own taps and swipes
can hand back the pieces it needs: `enableTouchGestures: false` leaves every
touch entry to the host (mouse, handles, toolbar and keyboard stay live),
`enableTouchConsecutiveTaps: false` keeps only long-press → word → drag,
`canStartSelectionAt` refuses selection starts on chrome (links, code
headers) without claiming the pointer, and `ownsSelectionChrome` decides
which scope paints handles and toolbar when several share one controller.
- **Edge autoscroll, any scroll protocol.** Dragging into the viewport edge
scrolls the host (`autoscroll:`, default `edgeZone: 48`). It drives the
nearest ancestor `Scrollable` out of the box, and anything else through
`MarkdownSelectionAutoscrollConfig.targetResolver` — an anchored chat
viewport, a `RenderBox` that positions children itself, a transform canvas.
- **Opt-in & compatible.** A `MarkdownWidget` with no `documentId`/controller is
inert — existing usage is unchanged.

Expand All @@ -296,6 +311,38 @@ MarkdownSelectionScope(
);
```

Driving a custom scroll implementation — the whole surface is three members
(`viewport`, `canScroll`, `applyScrollDelta`), and deltas are screen-space
content movement, so a reverse axis or an inverted anchor is the adapter's
problem, not the library's:

```dart
MarkdownSelectionScope(
controller: controller,
autoscroll: MarkdownSelectionAutoscrollConfig(
targetResolver: (request) => MarkdownCallbackAutoscrollTarget(
viewportOf: () {
final box = viewportKey.currentContext?.findRenderObject();
if (box is! RenderBox || !box.hasSize) return null;
return MarkdownAutoscrollViewport(
globalBounds: box.localToGlobal(Offset.zero) & box.size,
padding: const EdgeInsets.only(bottom: 72), // composer overlaps
);
},
canScrollAt: ({required forward}) =>
forward ? !chat.isAtTail.value : !chat.reachedOldest,
// This host's scrollBy is anchor-relative: positive reveals *older*
// messages, the opposite of screen-space movement.
onScrollDelta: (delta) {
chat.scrollBy(-delta);
return delta;
},
),
),
child: /* ... */,
);
```

See the runnable **Selection** and **Chat** tabs in `example/`.

## 🌊 Streaming (LLM output)
Expand Down Expand Up @@ -510,7 +557,7 @@ class _MyWidgetState extends State<MyWidget> {
## 📊 Performance

`flutter_md` is built for speed: a single-pass, lookup-table parser (regex-free
hot path with a plain-text fast path) and a custom render object that lays the
hot path with a plain text fast path) and a custom render object that lays the
whole document into one cached `ui.Picture` instead of a deep tree of per-block
widgets.

Expand Down
4 changes: 2 additions & 2 deletions benchmark_compare/benchmark/parser_benchmark.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ class _MarkdownPkgBenchmark extends BenchmarkBase {
void run() {
// A fresh Document per parse — exactly how flutter_markdown uses the
// package on every rebuild (Document is single-use / stateful).
final nodes = md.Document(extensionSet: md.ExtensionSet.gitHubFlavored)
.parse(input);
final nodes =
md.Document(extensionSet: md.ExtensionSet.gitHubFlavored).parse(input);
_sink ^= nodes.length;
}
}
3 changes: 1 addition & 2 deletions benchmark_compare/integration_test/scroll_perf_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ final Map<String, StyledFactory> _libraries = styledLibraries;
// and has no structural chrome, so any raster difference on it is due purely to
// the rendering model (single cached ui.Picture vs a widget tree), not layout
// density or code/table/list chrome.
const String _feedMode =
String.fromEnvironment('FEED', defaultValue: 'mixed');
const String _feedMode = String.fromEnvironment('FEED', defaultValue: 'mixed');

/// A realistic chat feed: representative documents, cycled. Excludes the huge
/// `complex_large` tier so many messages fit in a scrollable list.
Expand Down
Loading
Loading